From 4c4beebd02e3759bdd0b2592ac28bce3f7ba366e Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sat, 20 Jun 2026 18:42:34 +0300 Subject: [PATCH 1/9] fix: support assertView on older chrome versions, like chrome 53 --- src/browser/client-bridge/index.ts | 20 +++++++-- src/browser/client-scripts/calibrate.js | 9 +++- .../screen-shooter/utils/dom.ts | 3 +- .../client-scripts/shared/lib.compat.ts | 10 +++++ .../client-scripts/shared/lib.native.ts | 4 ++ src/browser/commands/assert-view/index.js | 32 ++++++++------ .../screen-shooter/elements-screen-shooter.ts | 4 +- .../wait-for-selectors-to-settle.ts | 42 ++++++++++++------ .../src/browser/commands/assert-view/index.js | 43 ++++++++++++++----- test/src/browser/screen-shooter/index.js | 22 +++++++++- 10 files changed, 144 insertions(+), 45 deletions(-) diff --git a/src/browser/client-bridge/index.ts b/src/browser/client-bridge/index.ts index 135d3754c..d24edd084 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 + }`; + } + 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/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts index e981866a9..21a302901 100644 --- a/src/browser/screen-shooter/elements-screen-shooter.ts +++ b/src/browser/screen-shooter/elements-screen-shooter.ts @@ -410,7 +410,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(); 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..0dd027a59 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; @@ -104,7 +117,7 @@ async function waitForSelectorsToSettleInBrowser( await new Promise(resolve => setTimeout(resolve, 5)); } - return { setTimeoutStubbed: false }; + return { success: true }; }, selectors); } catch (err) { executionError = err; @@ -129,7 +142,7 @@ async function waitForSelectorsToSettleInBrowser( throw executionError; } - return result ?? { setTimeoutStubbed: false }; + return result ?? { success: true }; } async function waitForSelectorsToSettleInNode(browser: WdioBrowser, selectors: string[]): Promise { @@ -150,9 +163,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/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")); From 1218c41c5e5ea4144fe2a55ea0faf6df92f0851c Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sun, 21 Jun 2026 20:11:24 +0300 Subject: [PATCH 2/9] fix: handle old browsers gracefully in time travel logic --- src/browser/history/index.ts | 5 +++ src/browser/history/rrweb.ts | 72 ++++++++++++++++++++++++++++-------- 2 files changed, 61 insertions(+), 16 deletions(-) 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..2add2fca5 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,42 @@ 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); + if (result.isRrwebSupported === false) { + debug("rrweb is not supported in this browser, error: %s", 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 this browser, error: %s", result.evalError); + sessionsWithUnsupportedRrweb.add(session); + + return []; + } + + return result.rrwebEvents; }); } @@ -46,9 +75,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 +86,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 +96,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 +115,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 +130,7 @@ function collectRrwebEvents( try { // @ts-expect-error return Boolean(window.rrweb); - } catch { + } catch (e) { return false; } }; @@ -112,7 +142,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 +189,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 From f540130eef618bde03546d61eafc6f7a8c813f51 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sun, 21 Jun 2026 20:11:53 +0300 Subject: [PATCH 3/9] fix: unsubscribe from signal handler in case of multiple quit() calls --- src/browser/new-browser.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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); From 39433d5c8b11292476fd2e32e947641f9d4cbdd5 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sun, 21 Jun 2026 22:37:38 +0300 Subject: [PATCH 4/9] fix: fix path resolutions in browser-env tests --- test/browser-env/tsconfig.json | 1 + test/browser-env/vite.config.ts | 1 + 2 files changed, 2 insertions(+) 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"), }, From c114a3c633a117db8528db8720af7738d827220b Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sun, 21 Jun 2026 22:49:03 +0300 Subject: [PATCH 5/9] fix: enhance debug logging for rrweb and client-bridge --- src/browser/client-bridge/index.ts | 2 +- src/browser/history/rrweb.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/browser/client-bridge/index.ts b/src/browser/client-bridge/index.ts index d24edd084..498051511 100644 --- a/src/browser/client-bridge/index.ts +++ b/src/browser/client-bridge/index.ts @@ -33,7 +33,7 @@ export class ClientBridge any>> { if (debug.enabled) { debugBrowserId = `${(browser as WdioBrowser)?.capabilities?.browserName} ${ (browser as WdioBrowser)?.capabilities?.browserVersion - }`; + }:${(browser as WdioBrowser)?.sessionId}`; } if (bundlesCache[scriptFilePath]) { diff --git a/src/browser/history/rrweb.ts b/src/browser/history/rrweb.ts index 2add2fca5..088fc2611 100644 --- a/src/browser/history/rrweb.ts +++ b/src/browser/history/rrweb.ts @@ -41,8 +41,15 @@ export async function installRrwebAndCollectEvents( 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 this browser, error: %s", result.evalError); + debug("rrweb is not supported in browser %s, error: %s", debugBrowserId, result.evalError); sessionsWithUnsupportedRrweb.add(session); return []; @@ -58,7 +65,7 @@ export async function installRrwebAndCollectEvents( result = await collectRrwebEvents(session, rrwebCode); if (result.isRrwebSupported === false) { - debug("rrweb is not supported in this browser, error: %s", result.evalError); + debug("rrweb is not supported in browser %s, error: %s", debugBrowserId, result.evalError); sessionsWithUnsupportedRrweb.add(session); return []; From a0c7be66c94e11547df8285495c7ac66540c95b8 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Sun, 21 Jun 2026 22:52:10 +0300 Subject: [PATCH 6/9] fix: fix formatting --- src/browser/history/rrweb.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/browser/history/rrweb.ts b/src/browser/history/rrweb.ts index 088fc2611..6083d2f93 100644 --- a/src/browser/history/rrweb.ts +++ b/src/browser/history/rrweb.ts @@ -43,9 +43,7 @@ export async function installRrwebAndCollectEvents( let debugBrowserId = ""; if (debug.enabled) { - debugBrowserId = `${session?.capabilities?.browserName} ${ - session?.capabilities?.browserVersion - }:${session?.sessionId}`; + debugBrowserId = `${session?.capabilities?.browserName} ${session?.capabilities?.browserVersion}:${session?.sessionId}`; } if (result.isRrwebSupported === false) { From cb7067c0f874bd27d1c9f666978bbc26d30de863 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Mon, 22 Jun 2026 03:35:15 +0300 Subject: [PATCH 7/9] fix: fix rollback invalid calculation when safe area changes mid-capture --- .../screen-shooter/elements-screen-shooter.ts | 95 +++++++++-------- .../chrome/sticky-safe-area-first-chunk.png | Bin 0 -> 10845 bytes .../delayed-fixed-header-after-scroll.html | 97 ++++++++++++++++++ test/e2e/tests/assert-view.testplane.js | 8 ++ 4 files changed, 156 insertions(+), 44 deletions(-) create mode 100644 test/e2e/screens/9345781/chrome/sticky-safe-area-first-chunk.png create mode 100644 test/e2e/static/delayed-fixed-header-after-scroll.html diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts index 21a302901..e76247996 100644 --- a/src/browser/screen-shooter/elements-screen-shooter.ts +++ b/src/browser/screen-shooter/elements-screen-shooter.ts @@ -150,18 +150,24 @@ 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">; @@ -452,52 +458,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/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 0000000000000000000000000000000000000000..30458dd352092452f18599a9f8544fd15730e4bf GIT binary patch literal 10845 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 GAO8k0!{0gp literal 0 HcmV?d00001 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"); From afe74862e742e2b56cbaa105bae9559c822faee7 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Mon, 22 Jun 2026 03:35:44 +0300 Subject: [PATCH 8/9] fix: do not use optional chaining when waiting for selectors to settle to support older browsers --- .../wait-for-selectors-to-settle.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) 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 0dd027a59..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 @@ -90,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 { From 0c95c4b13c9116ec390d9f3b3a504f9ebe55af64 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Mon, 22 Jun 2026 03:38:04 +0300 Subject: [PATCH 9/9] chore: fix formatting --- src/browser/screen-shooter/elements-screen-shooter.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/browser/screen-shooter/elements-screen-shooter.ts b/src/browser/screen-shooter/elements-screen-shooter.ts index e76247996..2d327d75d 100644 --- a/src/browser/screen-shooter/elements-screen-shooter.ts +++ b/src/browser/screen-shooter/elements-screen-shooter.ts @@ -165,7 +165,8 @@ function getSafeAreaRollbackDistance( 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); + previousCoveredBottom = + Math.min(previousVisibleBottom, previousSafeBottom) - (previousCaptureAreaTop as number); } const currentSafeAreaTop = (currentState.safeArea.top as number) - (currentCaptureAreaTop as number);