testplane
Version:
Tests framework based on mocha and wdio
477 lines • 27.4 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.ElementsScreenShooter = void 0;
const debug_1 = __importDefault(require("debug"));
const composite_image_1 = require("./composite-image");
const validation_1 = require("./validation");
const history_1 = require("../history");
const operations_1 = require("./operations");
const help_1 = require("../../constants/help");
const geometry_1 = require("../isomorphic/geometry");
const client_bridge_1 = require("../client-bridge");
const types_1 = require("../isomorphic/types");
const constants_1 = require("./constants");
const debug_2 = require("./debug");
class CaptureAreaSizeChangeError extends Error {
constructor() {
super("Capture area size changed unexpectedly during capture");
this.name = "CaptureAreaSizeChangeError";
}
}
const debug = (0, debug_2.makeVerboseScreenshotsDebug)("testplane:screenshots:elements-screen-shooter");
const SCROLL_OVERLAP_PX = 1;
const formatDuration = (duration) => `${duration.toFixed(1)}ms`;
function getMedian(values) {
if (values.length === 0) {
return null;
}
const sorted = values.slice().sort((a, b) => a - b);
const middleIndex = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) {
return sorted[middleIndex];
}
return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2;
}
function getExpectedTotalMoveFromBaseline(baselineCaptureSpecs, currentCaptureSpecs) {
const sharedSpecsCount = Math.min(baselineCaptureSpecs.length, currentCaptureSpecs.length);
const shifts = [];
for (let index = 0; index < sharedSpecsCount; index++) {
const baselineSpec = baselineCaptureSpecs[index];
const currentSpec = currentCaptureSpecs[index];
if (!baselineSpec || !currentSpec) {
continue;
}
const shift = currentSpec.full.top - baselineSpec.full.top;
if (shift !== 0) {
shifts.push(shift);
}
}
return getMedian(shifts) ?? 0;
}
function getMovingCaptureSpecs(currentState, lastState) {
if (currentState.scrollOffset === lastState.scrollOffset) {
return currentState.captureSpecs;
}
return currentState.captureSpecs.filter((spec, index) => {
const lastSpec = lastState.captureSpecs[index];
return lastSpec && spec.full.top !== lastSpec.full.top;
});
}
function getRemainingCaptureAreaHeight(captureSpecs, safeArea) {
if (captureSpecs.length === 0) {
return 0;
}
const safeAreaBottom = (0, geometry_1.getBottom)(safeArea);
const captureAreaBottom = Math.max(...captureSpecs.map(spec => (0, geometry_1.getBottom)(spec.full)));
return Math.max(0, captureAreaBottom - safeAreaBottom);
}
function getScrollDelta(safeAreaHeight, remainingCaptureAreaHeight) {
if (remainingCaptureAreaHeight <= safeAreaHeight) {
return remainingCaptureAreaHeight;
}
return (safeAreaHeight > SCROLL_OVERLAP_PX ? safeAreaHeight - SCROLL_OVERLAP_PX : safeAreaHeight);
}
function getCaptureAreaTop(captureSpecs) {
if (captureSpecs.length === 0) {
return null;
}
return Math.min(...captureSpecs.map(spec => spec.full.top));
}
function getSafeAreaRollbackDistance(lastState, currentState) {
const currentCaptureAreaTop = getCaptureAreaTop(currentState.captureSpecs);
if (currentCaptureAreaTop === null) {
return 0;
}
let previousCoveredBottom = 0;
const previousCaptureAreaTop = lastState ? getCaptureAreaTop(lastState.captureSpecs) : null;
if (lastState && previousCaptureAreaTop !== null) {
const previousVisibleBottom = Math.max(...lastState.captureSpecs.map(spec => (0, geometry_1.getBottom)(spec.visible)));
const previousSafeBottom = (0, geometry_1.getBottom)(lastState.safeArea);
previousCoveredBottom =
Math.min(previousVisibleBottom, previousSafeBottom) - previousCaptureAreaTop;
}
const currentSafeAreaTop = currentState.safeArea.top - currentCaptureAreaTop;
return Math.max(0, currentSafeAreaTop - previousCoveredBottom);
}
function getEmptyCaptureSpecsErrorMessage(selectorsToCapture) {
return (`Failed to capture element screenshot for selectors: ${selectorsToCapture.join("; ")}.\n` +
`Could not determine coordinates of the matched elements.\n` +
`Most likely the matched element became hidden, zero-sized, detached, moved offscreen, ` +
`or was clipped after scrolling/waiting for layout to settle.\n` +
`If you are capturing element sensitive to scrolling, like a tooltip, it could be hidden due to auto-scrolling on our side.\n` +
`Make sure the selector stays visible during the screenshot or disable scrolling via compositeImage/captureElementFromTop options.`);
}
class ElementsScreenShooter {
static async create(params) {
const browserSideScreenshooter = await client_bridge_1.ClientBridge.create(params.browser, "screen-shooter", { needsCompatLib: params.browserProperties.needsCompatLib });
return new this({ ...params, browserSideScreenshooter });
}
constructor({ browser, camera, browserProperties, browserSideScreenshooter }) {
this._browser = browser;
this._camera = camera;
this._browserProperties = browserProperties;
this._browserSideScreenshooter = browserSideScreenshooter;
}
async capture(selectorOrSelectors, opts = {}) {
const globalStartedAt = performance.now();
const perfDebug = (0, debug_1.default)("testplane:screenshots:perf:" + opts.debugId);
const selectorsToCapture = [].concat(selectorOrSelectors);
const selectorsToIgnore = [].concat(opts.ignoreElements ?? []);
if (selectorsToCapture.length === 0) {
throw new Error("No selectors to capture passed to ElementsScreenShooter.capture");
}
try {
perfDebug("capture: begin");
const page = await this._prepareScreenshot(selectorsToCapture, {
ignoreSelectors: selectorsToIgnore,
allowViewportOverflow: opts.allowViewportOverflow,
captureElementFromTop: opts.captureElementFromTop,
selectorToScroll: opts.selectorToScroll,
disableAnimation: opts.disableAnimation,
disableHover: opts.disableHover,
compositeImage: opts.compositeImage,
});
(0, validation_1.assertCorrectCaptureAreaBounds)(JSON.stringify(selectorsToCapture), page.viewportSize, page.viewportOffset, page.captureSpecs.map(s => s.full), opts);
await (0, operations_1.preparePointerForScreenshot)(this._browser, {
disableHover: opts.disableHover,
pointerEventsDisabled: page.pointerEventsDisabled,
});
let compositeImage;
try {
compositeImage = await this._performCaptureAttempt(selectorsToCapture, selectorsToIgnore, page, opts, true);
}
catch (error) {
if (!(error instanceof CaptureAreaSizeChangeError)) {
throw error;
}
perfDebug("capture: retrying in best-effort mode");
await this._preloadCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts);
compositeImage = await this._performCaptureAttempt(selectorsToCapture, selectorsToIgnore, page, opts, false);
}
const renderedImage = await compositeImage.render();
perfDebug(`capture: end in ${formatDuration(performance.now() - globalStartedAt)}`);
return {
image: renderedImage,
meta: page,
};
}
finally {
try {
await this._cleanupScreenshot(opts);
}
catch (cleanupError) {
const cleanupMessage = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
console.warn(`Warning: failed to cleanup after screenshot for selectors: ${JSON.stringify(selectorsToCapture)}\n` + `Cleanup error: ${cleanupMessage}`);
}
}
}
async _prepareScreenshot(selectorsToCapture, opts = {}) {
return (0, history_1.runWithoutHistory)({}, async () => {
const enabledDebugTopics = [];
const browserPrepareScreenshotDebug = (0, debug_2.makeVerboseScreenshotsDebug)("testplane:screenshots:browser:prepareScreenshot");
if (browserPrepareScreenshotDebug.enabled) {
enabledDebugTopics.push("prepareElementsScreenshot");
}
const extendedOpts = {
...opts,
debug: enabledDebugTopics,
usePixelRatio: this._browserProperties.shouldUsePixelRatio,
};
const result = await this._browserSideScreenshooter.call("prepareElementsScreenshot", [
selectorsToCapture,
extendedOpts,
]);
const { debugLog, ...resultRest } = result;
browserPrepareScreenshotDebug(debugLog);
debug("prepareElementsScreenshot result: %O", resultRest);
if ((0, types_1.isBrowserSideError)(result)) {
throw new Error(`Failed to perform the visual check, because we couldn't compute screenshot area to capture.\n\n` +
`What happened:\n` +
`- You called assertView command with the following selectors: ${JSON.stringify(selectorsToCapture)}\n` +
`- You passed the following options: ${JSON.stringify(extendedOpts)}\n` +
`- We tried to determine positions of these elements, but failed with the '${result.errorCode}' error: ${result.message}\n\n` +
`What you can do:\n` +
`- Check that passed selectors are valid and exist on the page\n` +
`- If you believe this is a bug on our side, re-run this test with DEBUG=testplane:screenshots* and file an issue with this log at ${help_1.NEW_ISSUE_LINK}\n`);
}
// https://github.com/webdriverio/webdriverio/issues/11396
if (this._browserProperties.isWebdriverProtocol && opts.disableAnimation) {
await (0, operations_1.disableIframeAnimations)(this._browser, this._browserSideScreenshooter);
}
return result;
});
}
async _cleanupScreenshot(opts = {}) {
return (0, history_1.runWithoutHistory)({}, async () => {
await (0, operations_1.cleanupScrolls)(this._browserSideScreenshooter);
if (opts.disableAnimation) {
await (0, operations_1.cleanupPageAnimations)(this._browser, this._browserSideScreenshooter, this._browserProperties.isWebdriverProtocol);
}
if (opts.disableHover && opts.disableHover !== "never") {
await (0, operations_1.cleanupPointerEvents)(this._browserSideScreenshooter);
}
});
}
/** Scrolls through the entire capture area to trigger lazy loading, then restores scroll and records anchor baselines. */
async _preloadCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts) {
const perfDebug = (0, debug_1.default)("testplane:screenshots:perf:" + opts.debugId);
perfDebug("preload capture area: begin");
try {
await this._scrollThroughCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts, async () => { });
await this._browserSideScreenshooter.call("scrollTo", [
selectorsToCapture,
page.scrollOffset,
opts.selectorToScroll ?? null,
]);
await this._browserSideScreenshooter.call("captureAnchorBaseline", [selectorsToCapture]);
}
finally {
perfDebug("preload capture area: end");
}
}
async _scrollThroughCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts, onNextScroll) {
const perfDebug = (0, debug_1.default)("testplane:screenshots:perf:" + opts.debugId);
let iterations = 0;
let lastState = {
captureSpecs: page.captureSpecs,
viewportOffset: page.viewportOffset,
scrollOffset: page.scrollOffset,
safeArea: page.safeArea,
ignoreAreas: page.ignoreAreas,
anchorShift: null,
};
let hasReachedScrollLimit = false;
let hasCapturedTheWholeArea = false;
const startTime = performance.now();
let waitForSettleTime = 0, recomputeTime = 0, scrollTime = 0, callbackTime = 0;
try {
while (iterations < constants_1.COMPOSITING_ITERATIONS_LIMIT && !hasCapturedTheWholeArea && !hasReachedScrollLimit) {
debug(`========== Starting compositing iteration #${iterations} ==========`);
const waitForSettleStartTime = performance.now();
await (0, operations_1.waitForSelectorsToSettle)(this._browser, selectorsToCapture, {
needsCompatLib: this._browserProperties.needsCompatLib,
});
waitForSettleTime += performance.now() - waitForSettleStartTime;
const recomputeStartTime = performance.now();
const enabledScrollDebugTopics = [];
const browserScrollDebug = (0, debug_2.makeVerboseScreenshotsDebug)("testplane:screenshots:browser:getCaptureState");
if (browserScrollDebug.enabled) {
enabledScrollDebugTopics.push("getCaptureState");
}
const currentStateOrError = await this._browserSideScreenshooter.call("getCaptureState", [
selectorsToCapture,
selectorsToIgnore,
opts.selectorToScroll,
enabledScrollDebugTopics,
]);
recomputeTime += performance.now() - recomputeStartTime;
const recomputeDebugLog = currentStateOrError.debugLog;
delete currentStateOrError.debugLog;
browserScrollDebug(recomputeDebugLog);
debug("currentState: %O", currentStateOrError);
if ((0, types_1.isBrowserSideError)(currentStateOrError)) {
throw new Error(`Failed to recompute areas while compositing image of selectors: ${selectorsToCapture.join(", ")}, error type '${currentStateOrError.errorCode}' and error message: ${currentStateOrError.message}`);
}
let currentState = currentStateOrError;
const safeAreaShrink = (lastState.safeArea.height - currentState.safeArea.height);
const safeAreaTopShift = currentState.safeArea.top - lastState.safeArea.top;
const rollbackDistance = safeAreaShrink > 0 || safeAreaTopShift > 0
? getSafeAreaRollbackDistance(iterations > 0 ? lastState : null, currentState)
: 0;
if (rollbackDistance > 0) {
debug("safe area shrank or shifted after scroll", {
previousSafeArea: lastState.safeArea,
newSafeArea: currentState.safeArea,
safeAreaShrink,
safeAreaTopShift,
rollbackDistance,
previousOffset: lastState.scrollOffset,
scrolledOffset: currentState.scrollOffset,
});
await this._browserSideScreenshooter.call("scrollBy", [
selectorsToCapture,
-rollbackDistance,
opts.selectorToScroll,
]);
const afterRollbackState = await this._browserSideScreenshooter.call("getCaptureState", [
selectorsToCapture,
selectorsToIgnore,
opts.selectorToScroll,
]);
if ((0, types_1.isBrowserSideError)(afterRollbackState)) {
throw new Error(`Failed to rollback and recompute areas while compositing image of selectors: ${selectorsToCapture.join(", ")}, error type '${afterRollbackState.errorCode}' and error message: ${afterRollbackState.message}`);
}
if (!afterRollbackState.safeArea || !afterRollbackState.ignoreAreas) {
throw new Error(`Failed to rollback and recompute full areas while compositing image of selectors: ${selectorsToCapture.join(", ")}`);
}
currentState = afterRollbackState;
}
const callbackStartTime = performance.now();
await onNextScroll(currentState);
callbackTime += performance.now() - callbackStartTime;
const movingCaptureSpecs = getMovingCaptureSpecs(currentState, lastState);
hasCapturedTheWholeArea = movingCaptureSpecs.every(s => (0, geometry_1.getBottom)(s.full) <= (0, geometry_1.getBottom)(currentState.safeArea));
if (hasCapturedTheWholeArea) {
break;
}
if (!opts.compositeImage) {
debug("compositeImage is false, exiting after the first iteration");
break;
}
hasReachedScrollLimit = iterations > 0 && currentState.scrollOffset <= lastState.scrollOffset;
if (hasReachedScrollLimit) {
break;
}
const remainingCaptureAreaHeight = getRemainingCaptureAreaHeight(movingCaptureSpecs, currentState.safeArea);
const scrollDelta = getScrollDelta(currentState.safeArea.height, remainingCaptureAreaHeight);
if (scrollDelta <= 0) {
hasCapturedTheWholeArea = true;
break;
}
debug("asking to scroll by %dpx (safeArea.height: %d, remaining moving capture area: %d)", scrollDelta, currentState.safeArea.height, remainingCaptureAreaHeight);
const scrollStartTime = performance.now();
const scrollResult = await this._browserSideScreenshooter.call("scrollBy", [
selectorsToCapture,
scrollDelta,
opts.selectorToScroll,
enabledScrollDebugTopics,
]);
scrollTime += performance.now() - scrollStartTime;
const scrollDebugLog = scrollResult.debugLog;
delete scrollResult.debugLog;
browserScrollDebug(scrollDebugLog);
debug("scrollResult: %O", scrollResult);
if ((0, types_1.isBrowserSideError)(scrollResult)) {
throw new Error(`Failed to scroll once while compositing image of selectors: ${selectorsToCapture.join(", ")}, error type '${scrollResult.errorCode}' and error message: ${scrollResult.message}`);
}
lastState = currentState;
iterations++;
}
}
finally {
perfDebug(` wait for stable capture area: ${formatDuration(waitForSettleTime)}`);
perfDebug(` recompute capture areas: ${formatDuration(recomputeTime)}`);
perfDebug(` scroll between chunks: ${formatDuration(scrollTime)}`);
perfDebug(` process current chunk: ${formatDuration(callbackTime)}`);
perfDebug(` loop overhead: ${formatDuration(performance.now() - startTime - (waitForSettleTime + recomputeTime + scrollTime + callbackTime))}`);
perfDebug(` scroll loop total: ${formatDuration(performance.now() - startTime)}`);
debug(`Scrolling finished after ${iterations} iterations, hasCapturedTheWholeArea: ${hasCapturedTheWholeArea}, hasReachedScrollLimit: ${hasReachedScrollLimit}`);
}
}
async _performCaptureAttempt(selectorsToCapture, selectorsToIgnore, page, opts, shouldThrowOnCaptureAreaSizeChange) {
const perfDebug = (0, debug_1.default)("testplane:screenshots:perf:" + opts.debugId);
const attemptMode = shouldThrowOnCaptureAreaSizeChange ? "strict" : "best-effort";
const image = composite_image_1.CompositeImage.create();
let timeSpentOnCapture = 0;
let iterations = 0;
let isOverflowingViewport = false;
let hasReachedScrollLimit = false;
let hasCapturedTheWholeArea = false;
let restoreScrollPositionError = null;
let lastState = {
viewportOffset: page.viewportOffset,
captureSpecs: page.captureSpecs,
scrollOffset: page.scrollOffset,
safeArea: page.safeArea,
ignoreAreas: page.ignoreAreas,
anchorShift: null,
};
let shouldRestoreScrollPosition = false;
perfDebug(`capture attempt (${attemptMode}): begin`);
try {
await this._scrollThroughCaptureArea(selectorsToCapture, selectorsToIgnore, page, opts, async (currentState) => {
if (currentState.captureSpecs.length === 0) {
if (iterations > 0) {
debug("Capture area disappeared after %d chunk(s), rendering already captured data for selectors: %s", iterations, selectorsToCapture.join("; "));
return;
}
throw new Error(getEmptyCaptureSpecsErrorMessage(selectorsToCapture));
}
const hasCaptureAreaSizeChanged = lastState.captureSpecs.length !== currentState.captureSpecs.length ||
lastState.captureSpecs.some((spec, index) => spec.full.width !== currentState.captureSpecs[index]?.full.width ||
spec.full.height !== currentState.captureSpecs[index]?.full.height);
if (hasCaptureAreaSizeChanged && shouldThrowOnCaptureAreaSizeChange) {
throw new CaptureAreaSizeChangeError();
}
const { captureSpecs: newCaptureSpecs, ignoreAreas: newIgnoreAreas, safeArea: newSafeArea, } = currentState;
const captureStartTime = performance.now();
const viewportImage = await this._camera.captureViewportImage({
viewportSize: page.viewportSize,
viewportOffset: currentState.viewportOffset,
screenshotDelay: opts.screenshotDelay,
cropMargins: opts.cropMargins,
});
timeSpentOnCapture += performance.now() - captureStartTime;
const expectedTotalMove = getExpectedTotalMoveFromBaseline(page.captureSpecs, newCaptureSpecs);
const observedTotalMove = currentState.anchorShift;
let correctionDelta = 0;
if (!shouldThrowOnCaptureAreaSizeChange && observedTotalMove !== null) {
correctionDelta = expectedTotalMove - observedTotalMove;
}
if (correctionDelta !== 0) {
debug("correctionDelta: %d (raw)", correctionDelta);
}
const correctionDeltaForComposite = Math.round(correctionDelta);
const correctionDeltaToApply = correctionDeltaForComposite === 0 ? 0 : -correctionDeltaForComposite;
await image.registerViewportImageAtOffset(viewportImage, newSafeArea, newCaptureSpecs, newIgnoreAreas, correctionDeltaToApply);
hasReachedScrollLimit = iterations > 0 && currentState.scrollOffset <= lastState.scrollOffset;
const movingCaptureSpecs = getMovingCaptureSpecs(currentState, lastState);
hasCapturedTheWholeArea = movingCaptureSpecs.every(s => (0, geometry_1.getBottom)(s.full) <= (0, geometry_1.getBottom)(newSafeArea));
isOverflowingViewport = newCaptureSpecs.some(s => (0, geometry_1.getBottom)(s.full) > page.viewportSize.height);
if (currentState.scrollOffset !== page.scrollOffset) {
shouldRestoreScrollPosition = true;
}
debug("newCaptureSpecs: %O", newCaptureSpecs);
debug("newSafeArea: %O", newSafeArea);
debug("lastState.captureSpecs: %O", lastState.captureSpecs);
lastState = currentState;
iterations++;
});
}
finally {
perfDebug(` raw viewport screenshots: ${formatDuration(timeSpentOnCapture)}`);
if (shouldRestoreScrollPosition) {
const enabledScrollDebugTopics = [];
const browserScrollDebug = (0, debug_1.default)("testplane:screenshots:browser:scrollTo");
if (browserScrollDebug.enabled) {
enabledScrollDebugTopics.push("scrollTo");
}
const restoreScrollResult = await this._browserSideScreenshooter.call("scrollTo", [
selectorsToCapture,
page.scrollOffset,
opts.selectorToScroll,
enabledScrollDebugTopics,
]);
const restoreScrollDebugLog = restoreScrollResult.debugLog;
delete restoreScrollResult.debugLog;
browserScrollDebug(restoreScrollDebugLog);
if ((0, types_1.isBrowserSideError)(restoreScrollResult)) {
restoreScrollPositionError = new Error(`Failed to restore scroll position after compositing image of selectors: ${selectorsToCapture.join(", ")}, error type '${restoreScrollResult.errorCode}' and error message: ${restoreScrollResult.message}`);
}
}
perfDebug(`capture attempt (${attemptMode}): end`);
}
if (restoreScrollPositionError) {
throw restoreScrollPositionError;
}
debug(`Compositing finished after ${iterations} iterations, hasCapturedTheWholeArea: ${hasCapturedTheWholeArea}, hasReachedScrollLimit: ${hasReachedScrollLimit}`);
if (isOverflowingViewport && !opts.allowViewportOverflow) {
console.warn(`Warning: when capturing the ${opts.debugId ?? selectorsToCapture.join(", ")} screenshot, we failed to capture the whole area.\n` +
`Here's what happened:\n` +
`- you requested to capture the following selectors: ${selectorsToCapture.join("; ")}\n` +
(opts.selectorToScroll
? `- you requested to scroll the following selector: ${opts.selectorToScroll}`
: `- we auto-detected element to scroll ${page.readableSelectorToScrollDescr} and tried scrolling it\n`) +
`- we reached the scroll limit, but weren't able to capture the whole area\n\n` +
`Here's what you can do:\n` +
`- set allowViewportOverflow to true in assertView options to silence this warning\n` +
`- check and adjust selectors that you want to capture or selectorToScroll`);
}
return image;
}
}
exports.ElementsScreenShooter = ElementsScreenShooter;
//# sourceMappingURL=elements-screen-shooter.js.map