UNPKG

testplane

Version:

Tests framework based on mocha and wdio

477 lines 27.4 kB
"use strict"; 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