diff --git a/src/browser/client-bridge/index.ts b/src/browser/client-bridge/index.ts index 135d3754c..498051511 100644 --- a/src/browser/client-bridge/index.ts +++ b/src/browser/client-bridge/index.ts @@ -3,6 +3,7 @@ import path from "path"; import fs from "fs"; import makeDebug from "debug"; import { ClientBridgeError } from "./error"; +import { WdioBrowser } from "../../types"; const debug = makeDebug("testplane:client-bridge"); @@ -28,13 +29,27 @@ export class ClientBridge any>> { const scriptFileName = needsCompatLib ? "bundle.compat.js" : "bundle.native.js"; const scriptFilePath = path.join(__dirname, "..", "client-scripts", namespace, "build", scriptFileName); + let debugBrowserId = ""; + if (debug.enabled) { + debugBrowserId = `${(browser as WdioBrowser)?.capabilities?.browserName} ${ + (browser as WdioBrowser)?.capabilities?.browserVersion + }:${(browser as WdioBrowser)?.sessionId}`; + } + if (bundlesCache[scriptFilePath]) { + debug( + `creating ClientBridge with cached script for namespace ${namespace} at ${scriptFilePath} for browser ${debugBrowserId}`, + ); return new ClientBridge(browser, bundlesCache[scriptFilePath], namespace); } const bundle = await fs.promises.readFile(scriptFilePath, { encoding: "utf8" }); bundlesCache[scriptFilePath] = bundle; + debug( + `creating ClientBridge with new script for namespace ${namespace} at ${scriptFilePath} for browser ${debugBrowserId}`, + ); + return new this(browser, bundle, namespace); } @@ -89,10 +104,7 @@ export class ClientBridge any>> { } private async _inject(): Promise { - debug(` > injecting script into namespace ${this._namespace}`); - if (debug.enabled) { - console.log(this._script); - } + debug(` > injecting script into namespace ${this._namespace}: ${this._script.slice(0, 256)}...`); await this._browser.execute(this._script, this._namespace); } } diff --git a/src/browser/client-scripts/calibrate.js b/src/browser/client-scripts/calibrate.js index 23ddef3c2..2f88c994a 100644 --- a/src/browser/client-scripts/calibrate.js +++ b/src/browser/client-scripts/calibrate.js @@ -53,7 +53,14 @@ } function needsCompatLib() { - return !hasCSS3Selectors() || !window.getComputedStyle || !window.matchMedia || !String.prototype.trim; + return ( + !hasCSS3Selectors() || + !window.getComputedStyle || + !window.matchMedia || + !String.prototype.trim || + !window.Node || + !window.Node.prototype.getRootNode + ); } // In safari `window.innerWidth` always returns default 980px and and even viewport meta tag setting does not change it. diff --git a/src/browser/client-scripts/screen-shooter/utils/dom.ts b/src/browser/client-scripts/screen-shooter/utils/dom.ts index 54f958317..66f3bfb7b 100644 --- a/src/browser/client-scripts/screen-shooter/utils/dom.ts +++ b/src/browser/client-scripts/screen-shooter/utils/dom.ts @@ -1,3 +1,4 @@ +import * as lib from "@lib"; import { ScreenshooterNamespaceData } from "../types"; declare global { @@ -59,7 +60,7 @@ export function forEachRoot(cb: (root: Element | ShadowRoot) => void): void { export function getParentElement(node: Node): Element | null { if (node instanceof ShadowRoot) return node.host; if (node instanceof Element) { - const root = node.getRootNode(); + const root = lib.getRootNode(node); return node.parentElement || (root instanceof ShadowRoot ? root.host : null); } return node.parentNode instanceof Element ? node.parentNode : null; diff --git a/src/browser/client-scripts/shared/lib.compat.ts b/src/browser/client-scripts/shared/lib.compat.ts index a240d7807..6dc39d41e 100644 --- a/src/browser/client-scripts/shared/lib.compat.ts +++ b/src/browser/client-scripts/shared/lib.compat.ts @@ -23,5 +23,15 @@ export function trim(str: string): string { return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, ""); } +export function getRootNode(node: Node): Node { + let root = node; + + while (root.parentNode) { + root = root.parentNode; + } + + return root; +} + export { getComputedStyle } from "./polyfills/getComputedStyle"; export { matchMedia } from "./polyfills/matchMedia"; diff --git a/src/browser/client-scripts/shared/lib.native.ts b/src/browser/client-scripts/shared/lib.native.ts index a3c259ebe..17b11ee8d 100644 --- a/src/browser/client-scripts/shared/lib.native.ts +++ b/src/browser/client-scripts/shared/lib.native.ts @@ -25,3 +25,7 @@ export function matchMedia(mediaQuery: string): MediaQueryList { export function trim(str: string): string { return str.trim(); } + +export function getRootNode(node: Node): Node { + return node.getRootNode(); +} diff --git a/src/browser/commands/assert-view/index.js b/src/browser/commands/assert-view/index.js index 626122bb5..4d559d273 100644 --- a/src/browser/commands/assert-view/index.js +++ b/src/browser/commands/assert-view/index.js @@ -36,18 +36,8 @@ const getIgnoreDiffPixelCountRatio = value => { }; module.exports.default = browser => { - const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser; - const browserProperties = { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib }; - const elementsScreenShooterPromise = ElementsScreenShooter.create({ - camera: browser.camera, - browser: browser.publicAPI, - browserProperties, - }); - const viewportScreenShooterPromise = ViewportScreenShooter.create({ - camera: browser.camera, - browser: browser.publicAPI, - browserProperties, - }); + let elementsScreenShooterPromise; + let viewportScreenShooterPromise; const { publicAPI: session, config } = browser; const { assertViewOpts, @@ -192,6 +182,15 @@ module.exports.default = browser => { debug(`[${debugId}] assertView selectors: %O`, selectors); debug(`[${debugId}] assertView opts: %O`, opts); + if (!elementsScreenShooterPromise) { + const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser; + elementsScreenShooterPromise = ElementsScreenShooter.create({ + camera: browser.camera, + browser: browser.publicAPI, + browserProperties: { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib }, + }); + } + const screenShooter = await elementsScreenShooterPromise; await waitForStaticToLoad(opts); const { image, meta } = await screenShooter.capture(selectors, opts); @@ -238,6 +237,15 @@ module.exports.default = browser => { debug(`assertViewByViewport state: ${state}, opts: %O`, opts); + if (!viewportScreenShooterPromise) { + const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser; + viewportScreenShooterPromise = ViewportScreenShooter.create({ + camera: browser.camera, + browser: browser.publicAPI, + browserProperties: { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib }, + }); + } + const vpScreenShooter = await viewportScreenShooterPromise; await waitForStaticToLoad(opts); const { image, meta } = await vpScreenShooter.capture(opts); diff --git a/src/browser/history/index.ts b/src/browser/history/index.ts index 03b15b91a..9a874faf5 100644 --- a/src/browser/history/index.ts +++ b/src/browser/history/index.ts @@ -9,6 +9,7 @@ import { cleanupRrweb, filterEvents, installRrwebAndCollectEvents, sendFilteredE import { getHistoryContext, runWithHistoryContext } from "./async-local-storage"; const debug = makeDebug("testplane:browser:history"); +const debugTimeTravel = makeDebug("testplane:time-travel:history"); interface NodeData { name: string; @@ -79,8 +80,10 @@ export const requestDomSnapshots = ({ attempt, currentTest, }: RequestDomSnapshotsData): void => { + debugTimeTravel("requestDomSnapshots, called"); try { if (!callstack) { + debugTimeTravel("requestDomSnapshots, callstack is not defined"); return; } @@ -90,6 +93,7 @@ export const requestDomSnapshots = ({ const test = currentTest ?? session.executionContext?.ctx?.currentTest; if (shouldRecord && process.send && test) { + debugTimeTravel("requestDomSnapshots, shouldRecord and process.send and test are true"); const rrwebPromise = installRrwebAndCollectEvents(session, callstack) .then(rrwebEvents => { const rrwebEventsFiltered = filterEvents(rrwebEvents); @@ -101,6 +105,7 @@ export const requestDomSnapshots = ({ snapshotsPromiseRef.current = snapshotsPromiseRef.current.then(() => rrwebPromise); } + debugTimeTravel("requestDomSnapshots, done"); } catch (e) { debug("An error occurred during capturing snapshots in browser: %O", e); } diff --git a/src/browser/history/rrweb.ts b/src/browser/history/rrweb.ts index aa58f3be2..6083d2f93 100644 --- a/src/browser/history/rrweb.ts +++ b/src/browser/history/rrweb.ts @@ -1,20 +1,26 @@ import fs from "fs"; +import path from "path"; import { eventWithTime } from "@rrweb/types"; +import makeDebug from "debug"; import type { Callstack } from "./callstack"; import { MasterEvents } from "../../events"; import type { SnapshotsData, Test, TestContext } from "../../types"; import { runWithoutHistory } from "./index"; -import path from "path"; + +const debug = makeDebug("testplane:time-travel:rrweb"); // Built from branch https://github.com/gemini-testing/rrweb/tree/TESTPLANE-712.syntax_err // PR: https://github.com/rrweb-io/rrweb/pull/1735 // Issue: https://github.com/rrweb-io/rrweb/issues/1734 const rrwebCode = fs.readFileSync(path.join(__dirname, "../client-scripts/rrweb-record.min.js"), "utf-8"); -const sessionsWithRrwebRequested = new WeakSet(); +const sessionsWithRrwebSent = new WeakSet(); +const sessionsWithUnsupportedRrweb = new WeakSet(); interface CollectRrwebEventsResult { + isRrwebSupported?: false; isRrwebInstalled: boolean; rrwebEvents: eventWithTime[]; + evalError?: string; } /* eslint-disable @typescript-eslint/ban-ts-comment */ @@ -23,19 +29,47 @@ export async function installRrwebAndCollectEvents( callstack: Callstack, ): Promise { return runWithoutHistory>({ callstack }, async () => { - const shouldSendRrwebCode = !sessionsWithRrwebRequested.has(session); + if (sessionsWithUnsupportedRrweb.has(session)) { + return []; + } + + const shouldSendRrwebCode = !sessionsWithRrwebSent.has(session); if (shouldSendRrwebCode) { - sessionsWithRrwebRequested.add(session); + sessionsWithRrwebSent.add(session); } - const result = await collectRrwebEvents(session, shouldSendRrwebCode ? rrwebCode : null); + let result = await collectRrwebEvents(session, shouldSendRrwebCode ? rrwebCode : undefined); + let debugBrowserId = ""; + if (debug.enabled) { + debugBrowserId = `${session?.capabilities?.browserName} ${session?.capabilities?.browserVersion}:${session?.sessionId}`; + } + + if (result.isRrwebSupported === false) { + debug("rrweb is not supported in browser %s, error: %s", debugBrowserId, result.evalError); + sessionsWithUnsupportedRrweb.add(session); + + return []; + } + + // If rrweb is installed (success) or we already provided the code (no point in trying again) if (result.isRrwebInstalled || shouldSendRrwebCode) { return result.rrwebEvents; } - return (await collectRrwebEvents(session, rrwebCode)).rrwebEvents; + debug("collectRrwebEvents, rrweb was not installed, sending code again"); + + result = await collectRrwebEvents(session, rrwebCode); + + if (result.isRrwebSupported === false) { + debug("rrweb is not supported in browser %s, error: %s", debugBrowserId, result.evalError); + sessionsWithUnsupportedRrweb.add(session); + + return []; + } + + return result.rrwebEvents; }); } @@ -46,9 +80,9 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call try { // @ts-expect-error const rrwebEvents = window.rrwebEvents; - const rrwebData = rrwebEvents?.testplane; + const rrwebData = rrwebEvents && rrwebEvents.testplane; - if (rrwebData?.stopRecording) { + if (rrwebData && rrwebData.stopRecording) { try { rrwebData.stopRecording(); } catch (e) { @@ -57,8 +91,8 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call } try { - const colorSchemeMedia = rrwebData?.colorSchemeMedia; - const colorSchemeListener = rrwebData?.colorSchemeListener; + const colorSchemeMedia = rrwebData && rrwebData.colorSchemeMedia; + const colorSchemeListener = rrwebData && rrwebData.colorSchemeListener; if (colorSchemeMedia && colorSchemeListener) { colorSchemeMedia.removeEventListener("change", colorSchemeListener); @@ -67,7 +101,7 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call /**/ } - if (rrwebData?.isInstalledByTestplane) { + if (rrwebData && rrwebData.isInstalledByTestplane) { // @ts-expect-error delete window.rrweb; @@ -86,13 +120,14 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call } catch (e) { /**/ } finally { - sessionsWithRrwebRequested.delete(session); + sessionsWithRrwebSent.delete(session); + sessionsWithUnsupportedRrweb.delete(session); } } function collectRrwebEvents( session: WebdriverIO.Browser, - rrwebRecordFnCode: string | null, + rrwebRecordFnCode: string | undefined, ): Promise { return session.execute( (rrwebRecordFnCode, serverTime) => { @@ -100,7 +135,7 @@ function collectRrwebEvents( try { // @ts-expect-error return Boolean(window.rrweb); - } catch { + } catch (e) { return false; } }; @@ -112,7 +147,7 @@ function collectRrwebEvents( result = window.rrwebEvents.slice(window.lastProcessedRrwebEvent + 1); // @ts-expect-error window.lastProcessedRrwebEvent = window.rrwebEvents.length - 1; - } catch { + } catch (e) { result = []; } @@ -159,7 +194,17 @@ function collectRrwebEvents( try { if (!isRrwebInstalled() && rrwebRecordFnCode) { - window.eval(rrwebRecordFnCode); + try { + window.eval(rrwebRecordFnCode); + } catch (e) { + return { + isRrwebSupported: false, + isRrwebInstalled: false, + rrwebEvents: [], + evalError: String(e), + }; + } + // @ts-expect-error window.lastProcessedRrwebEvent = -1; // @ts-expect-error diff --git a/src/browser/new-browser.ts b/src/browser/new-browser.ts index 12d7e4295..3829860a7 100644 --- a/src/browser/new-browser.ts +++ b/src/browser/new-browser.ts @@ -54,10 +54,13 @@ const headlessBrowserOptions: HeadlessBrowserOptions = { }; export class NewBrowser extends Browser { + private _onExit: (err?: Error) => Promise = async () => {}; + constructor(config: Config, opts: BrowserOpts) { super(config, opts); - signalHandler.on("exit", (err?: Error) => this.quit(err)); + this._onExit = async (err?: Error): Promise => await this.quit(err); + signalHandler.on("exit", this._onExit); } async init(): Promise { @@ -76,6 +79,7 @@ export class NewBrowser extends Browser { async quit(err?: Error): Promise { this._exitError = err; + signalHandler.off("exit", this._onExit); try { this.setHttpTimeout(this._config.sessionQuitTimeout); diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts index e981866a9..2d327d75d 100644 --- a/src/browser/screen-shooter/elements-screen-shooter.ts +++ b/src/browser/screen-shooter/elements-screen-shooter.ts @@ -150,18 +150,25 @@ function getCaptureAreaTop(captureSpecs: CaptureState["captureSpecs"]): Coord<"v return Math.min(...captureSpecs.map(spec => spec.full.top as number)) as Coord<"viewport", "device", "y">; } -function getSafeAreaRollbackDistance(lastState: CaptureState, currentState: CaptureState): Length<"device", "y"> { - const previousCaptureAreaTop = getCaptureAreaTop(lastState.captureSpecs); +function getSafeAreaRollbackDistance( + lastState: CaptureState | null, + currentState: CaptureState, +): Length<"device", "y"> { const currentCaptureAreaTop = getCaptureAreaTop(currentState.captureSpecs); - if (previousCaptureAreaTop === null || currentCaptureAreaTop === null) { + if (currentCaptureAreaTop === null) { return 0 as Length<"device", "y">; } - const previousVisibleBottom = Math.max(...lastState.captureSpecs.map(spec => getBottom(spec.visible))); - const previousSafeBottom = getBottom(lastState.safeArea); - const previousCoveredBottom = - Math.min(previousVisibleBottom, previousSafeBottom) - (previousCaptureAreaTop as number); + let previousCoveredBottom = 0; + const previousCaptureAreaTop = lastState ? getCaptureAreaTop(lastState.captureSpecs) : null; + if (lastState && previousCaptureAreaTop !== null) { + const previousVisibleBottom = Math.max(...lastState.captureSpecs.map(spec => getBottom(spec.visible))); + const previousSafeBottom = getBottom(lastState.safeArea); + previousCoveredBottom = + Math.min(previousVisibleBottom, previousSafeBottom) - (previousCaptureAreaTop as number); + } + const currentSafeAreaTop = (currentState.safeArea.top as number) - (currentCaptureAreaTop as number); return Math.max(0, currentSafeAreaTop - previousCoveredBottom) as Length<"device", "y">; @@ -410,7 +417,9 @@ export class ElementsScreenShooter { debug(`========== Starting compositing iteration #${iterations} ==========`); const waitForSettleStartTime = performance.now(); - await waitForSelectorsToSettle(this._browser, selectorsToCapture); + await waitForSelectorsToSettle(this._browser, selectorsToCapture, { + needsCompatLib: this._browserProperties.needsCompatLib, + }); waitForSettleTime += performance.now() - waitForSettleStartTime; const recomputeStartTime = performance.now(); @@ -450,52 +459,53 @@ export class ElementsScreenShooter { "device", "y" >; - - if (safeAreaShrink > 0) { - // Roll back only if the new safe-area start would leave a gap after the previous chunk. - const rollbackDistance = getSafeAreaRollbackDistance(lastState, currentState); - - debug("safe area shrank after scroll", { + const safeAreaTopShift = (currentState.safeArea.top as number) - (lastState.safeArea.top as number); + 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, }); - if (rollbackDistance > 0) { - await this._browserSideScreenshooter.call("scrollBy", [ - selectorsToCapture, - -rollbackDistance as Coord<"page", "device", "y">, - opts.selectorToScroll, - ]); - const afterRollbackState = await this._browserSideScreenshooter.call("getCaptureState", [ - selectorsToCapture, - selectorsToIgnore, - opts.selectorToScroll, - ]); - - if (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; + await this._browserSideScreenshooter.call("scrollBy", [ + selectorsToCapture, + -rollbackDistance as Coord<"page", "device", "y">, + opts.selectorToScroll, + ]); + const afterRollbackState = await this._browserSideScreenshooter.call("getCaptureState", [ + selectorsToCapture, + selectorsToIgnore, + opts.selectorToScroll, + ]); + + if (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(); diff --git a/src/browser/screen-shooter/operations/wait-for-selectors-to-settle.ts b/src/browser/screen-shooter/operations/wait-for-selectors-to-settle.ts index bdd05cbd1..9c2f86067 100644 --- a/src/browser/screen-shooter/operations/wait-for-selectors-to-settle.ts +++ b/src/browser/screen-shooter/operations/wait-for-selectors-to-settle.ts @@ -10,18 +10,26 @@ const SELECTORS_SETTLE_FALLBACK_ATTEMPTS = 5; type SelectorRect = { top: number; height: number } | null; interface BrowserSelectorsSettleResult { - setTimeoutStubbed: boolean; + success: boolean; } -export async function waitForSelectorsToSettle(browser: WdioBrowser, selectors: string[]): Promise { +interface WaitForSelectorsToSettleOptions { + needsCompatLib?: boolean; +} + +export async function waitForSelectorsToSettle( + browser: WdioBrowser, + selectors: string[], + options: WaitForSelectorsToSettleOptions = {}, +): Promise { try { - const settleResult = await waitForSelectorsToSettleInBrowser(browser, selectors); + const settleResult = await waitForSelectorsToSettleInBrowser(browser, selectors, options); - if (!settleResult.setTimeoutStubbed) { + if (settleResult.success) { return; } - debug("Browser-side waitForSelectorsToSettle detected stubbed setTimeout, using Node-side polling"); + debug("Browser-side waitForSelectorsToSettle cannot run, using Node-side polling"); } catch (err) { const scriptTimeoutError = err as { name?: string; message?: string; error?: string }; const scriptTimeoutErrorText = @@ -42,9 +50,14 @@ export async function waitForSelectorsToSettle(browser: WdioBrowser, selectors: async function waitForSelectorsToSettleInBrowser( browser: WdioBrowser, selectors: string[], + options: WaitForSelectorsToSettleOptions, ): Promise { - const originalTimeouts = await browser.getTimeouts(); - const originalScriptTimeout = originalTimeouts.script; + if (options.needsCompatLib) { + return { success: false }; + } + + const originalScriptTimeout = + typeof browser.getTimeouts === "function" ? (await browser.getTimeouts())?.script : undefined; const shouldRestoreScriptTimeout = typeof originalScriptTimeout === "number"; let executionError: unknown = null; @@ -62,11 +75,11 @@ async function waitForSelectorsToSettleInBrowser( try { setTimeoutSource = typeof setTimeout === "function" ? Function.prototype.toString.call(setTimeout) : ""; } catch { - return { setTimeoutStubbed: true }; + return { success: false }; } if (!setTimeoutSource.includes("[native code]")) { - return { setTimeoutStubbed: true }; + return { success: false }; } const PAGE_SETTLE_MAX_WAIT_MS = 50; @@ -77,23 +90,31 @@ async function waitForSelectorsToSettleInBrowser( let matches = 0; - let lastBoundingClientRects = selectors.map(selector => - document.querySelector(selector)?.getBoundingClientRect(), - ); + let lastBoundingClientRects = selectors.map(selector => { + const element = document.querySelector(selector); + + return element ? element.getBoundingClientRect() : null; + }); while ( performance.now() - startedAt < PAGE_SETTLE_MAX_WAIT_MS && iterations < PAGE_SETTLE_MAX_ITERATIONS && matches < PAGE_SETTLE_MATCHES_THRESHOLD ) { - const currentBoundingClientRects = selectors.map(selector => - document.querySelector(selector)?.getBoundingClientRect(), - ); + const currentBoundingClientRects = selectors.map(selector => { + const element = document.querySelector(selector); + + return element ? element.getBoundingClientRect() : null; + }); if ( - currentBoundingClientRects.every( - (rect, index) => - rect?.top === lastBoundingClientRects[index]?.top && - rect?.height === lastBoundingClientRects[index]?.height, - ) + currentBoundingClientRects.every((rect, index) => { + const lastRect = lastBoundingClientRects[index]; + + if (!rect || !lastRect) { + return rect === lastRect; + } + + return rect.top === lastRect.top && rect.height === lastRect.height; + }) ) { matches++; } else { @@ -104,7 +125,7 @@ async function waitForSelectorsToSettleInBrowser( await new Promise(resolve => setTimeout(resolve, 5)); } - return { setTimeoutStubbed: false }; + return { success: true }; }, selectors); } catch (err) { executionError = err; @@ -129,7 +150,7 @@ async function waitForSelectorsToSettleInBrowser( throw executionError; } - return result ?? { setTimeoutStubbed: false }; + return result ?? { success: true }; } async function waitForSelectorsToSettleInNode(browser: WdioBrowser, selectors: string[]): Promise { @@ -150,9 +171,10 @@ async function waitForSelectorsToSettleInNode(browser: WdioBrowser, selectors: s } const previousBoundingClientRects = lastBoundingClientRects; - const currentBoundingClientRects: SelectorRect[] = await browser.execute(selectors => { - return selectors.map((selector): SelectorRect => { - const rect = document.querySelector(selector)?.getBoundingClientRect(); + const currentBoundingClientRects: SelectorRect[] = await browser.execute(function (selectors: string[]) { + return selectors.map(function (selector): SelectorRect { + const element = document.querySelector(selector); + const rect = element ? element.getBoundingClientRect() : null; return rect ? { top: rect.top, height: rect.height } : null; }); diff --git a/test/browser-env/tsconfig.json b/test/browser-env/tsconfig.json index 0c8f9666e..ebe958111 100644 --- a/test/browser-env/tsconfig.json +++ b/test/browser-env/tsconfig.json @@ -9,6 +9,7 @@ "skipLibCheck": true, "types": [], "paths": { + "@lib": ["../../src/browser/client-scripts/shared/lib.native.ts"], "@isomorphic": ["../../src/browser/isomorphic/index.ts"] } }, diff --git a/test/browser-env/vite.config.ts b/test/browser-env/vite.config.ts index 4c244136f..955a10c61 100644 --- a/test/browser-env/vite.config.ts +++ b/test/browser-env/vite.config.ts @@ -32,6 +32,7 @@ export default defineConfig({ resolve: { alias: { "@isomorphic": path.resolve(__dirname, "../../src/browser/isomorphic/index.ts"), + "@lib": path.resolve(__dirname, "../../src/browser/client-scripts/shared/lib.native.ts"), "@": path.resolve(__dirname, "../../lib"), lib: path.resolve(__dirname, "../../lib"), }, diff --git a/test/e2e/screens/9345781/chrome/sticky-safe-area-first-chunk.png b/test/e2e/screens/9345781/chrome/sticky-safe-area-first-chunk.png new file mode 100644 index 000000000..30458dd35 Binary files /dev/null and b/test/e2e/screens/9345781/chrome/sticky-safe-area-first-chunk.png differ diff --git a/test/e2e/static/delayed-fixed-header-after-scroll.html b/test/e2e/static/delayed-fixed-header-after-scroll.html new file mode 100644 index 000000000..019627f77 --- /dev/null +++ b/test/e2e/static/delayed-fixed-header-after-scroll.html @@ -0,0 +1,97 @@ + + + + + + Delayed Fixed Header After Scroll + + + +
Fixed header must not be captured
+
+ +
+
Target top
+
Target middle
+
Target bottom
+
+ + + + diff --git a/test/e2e/tests/assert-view.testplane.js b/test/e2e/tests/assert-view.testplane.js index b3b94b7d3..68ccd469b 100644 --- a/test/e2e/tests/assert-view.testplane.js +++ b/test/e2e/tests/assert-view.testplane.js @@ -53,6 +53,14 @@ describe("assertView", () => { }); }); + it("should rollback first chunk when sticky header appears after scroll", async ({ browser }) => { + await browser.url("delayed-fixed-header-after-scroll.html"); + + await browser.assertView("sticky-safe-area-first-chunk", "[data-testid=capture-target]", { + captureElementFromTop: true, + }); + }); + it("should treat sticky content inside capture target as interference", async ({ browser }) => { await browser.url("sticky-interference-behind-capture-target.html"); diff --git a/test/src/browser/commands/assert-view/index.js b/test/src/browser/commands/assert-view/index.js index 8a4d81bd1..d48575fd4 100644 --- a/test/src/browser/commands/assert-view/index.js +++ b/test/src/browser/commands/assert-view/index.js @@ -180,6 +180,28 @@ describe("assertView command", () => { assert.calledOnceWith(ViewportScreenShooter.prototype.capture, sinon.match({ screenshotDelay: 0 })); }); + it("should lazily create viewport screen shooter with current browser properties", async () => { + const browser = await initBrowser_(); + + sandbox.stub(browser, "needsCompatLib").get(() => true); + + assert.notCalled(ElementsScreenShooter.create); + assert.notCalled(ViewportScreenShooter.create); + + await browser.publicAPI.assertView("plain"); + + assert.notCalled(ElementsScreenShooter.create); + assert.calledOnceWithMatch(ViewportScreenShooter.create, { + camera: browser.camera, + browser: browser.publicAPI, + browserProperties: sinon.match({ + isWebdriverProtocol: true, + shouldUsePixelRatio: true, + needsCompatLib: true, + }), + }); + }); + it("should add custom options if selector is not provided", async () => { const browser = await initBrowser_(); @@ -359,27 +381,26 @@ describe("assertView command", () => { }); describe("take screenshot", () => { - it("should create an instance of a screen shooter", async () => { + it("should lazily create an instance of elements screen shooter", async () => { const browser = await initBrowser_(); + sandbox.stub(browser, "needsCompatLib").get(() => true); + + assert.notCalled(ElementsScreenShooter.create); + assert.notCalled(ViewportScreenShooter.create); + + await fn(browser); + assert.calledOnceWithMatch(ElementsScreenShooter.create, { camera: browser.camera, browser: browser.publicAPI, browserProperties: sinon.match({ isWebdriverProtocol: true, shouldUsePixelRatio: true, - needsCompatLib: false, - }), - }); - assert.calledOnceWithMatch(ViewportScreenShooter.create, { - camera: browser.camera, - browser: browser.publicAPI, - browserProperties: sinon.match({ - isWebdriverProtocol: true, - shouldUsePixelRatio: true, - needsCompatLib: false, + needsCompatLib: true, }), }); + assert.notCalled(ViewportScreenShooter.create); }); it("should capture a screenshot image", async () => { diff --git a/test/src/browser/screen-shooter/index.js b/test/src/browser/screen-shooter/index.js index 025617750..2cb7aeed1 100644 --- a/test/src/browser/screen-shooter/index.js +++ b/test/src/browser/screen-shooter/index.js @@ -650,7 +650,7 @@ describe("ElementsScreenShooter", () => { it("should fall back to Node-side polling when browser-side code detects stubbed setTimeout", async () => { browser.execute .onCall(0) - .resolves({ setTimeoutStubbed: true }) + .resolves({ success: false }) .onCall(1) .resolves([{ top: 1, height: 2 }]) .onCall(2) @@ -669,6 +669,16 @@ describe("ElementsScreenShooter", () => { assert.callCount(browser.execute, 5); }); + it("should use Node-side polling when browser needs compat lib", async () => { + browser.execute.resolves([{ top: 1, height: 2 }]); + + await waitForSelectorsToSettle(browser, [".element"], { needsCompatLib: true }); + + assert.notCalled(browser.getTimeouts); + assert.notCalled(browser.setTimeout); + assert.callCount(browser.execute, 4); + }); + it("should fall back to Node-side polling when browser-side code hits script timeout", async () => { const scriptTimeoutError = new Error("script timeout"); @@ -692,6 +702,16 @@ describe("ElementsScreenShooter", () => { assert.callCount(browser.execute, 5); }); + it("should not override script timeout if getTimeouts is not available", async () => { + delete browser.getTimeouts; + browser.execute.resolves({ success: true }); + + await waitForSelectorsToSettle(browser, [".element"]); + + assert.notCalled(browser.setTimeout); + assert.calledOnce(browser.execute); + }); + it("should propagate non-timeout browser-side errors", async () => { browser.execute.rejects(new Error("boom"));