diff --git a/.changeset/fix-1146-bidi-viewport-origin.md b/.changeset/fix-1146-bidi-viewport-origin.md new file mode 100644 index 000000000..0f7ae399a --- /dev/null +++ b/.changeset/fix-1146-bidi-viewport-origin.md @@ -0,0 +1,58 @@ +--- +"@wdio/image-comparison-core": patch +"@wdio/visual-service": patch +--- + +## #1146 Fix BiDi element screenshots missing composited layers (scrollbars, fixed/sticky overlays) + +### Root cause + +When `checkElement` / `saveElement` is used with the WebDriver BiDi protocol, the screenshot was taken with `browsingContext.captureScreenshot` using `origin: 'document'`. This renders the document layout independently of the browser's compositor, which means **composited layers are never included** — element-level scrollbars, `position: fixed` / `position: sticky` overlays, and elements with a `will-change` CSS property all render as invisible or without their correct visual state. + +The switch to `origin: 'document'` was introduced in an earlier fix (commit `227f10a`) to avoid a `zero dimensions` error that occurred when `origin: 'viewport'` was used for elements that were outside the visible viewport. That fix was correct for out-of-viewport elements, but it also silently broke composited-layer capture for all elements. + +### Fix: new `biDiOrigin` method option + +A new **method-level** option `biDiOrigin` has been added to `saveElement` / `checkElement`. It is BiDi-only and ignored for the legacy WebDriver screenshot path. + +| Value | Behaviour | +|---|---| +| `'document'` *(default)* | Previous behaviour — works for any element position but composited layers (scrollbars, overlays, `will-change`) are not captured | +| `'viewport'` | Captures the composited frame as the browser painted it — scrollbars, fixed/sticky overlays and `will-change` layers are included. The element must be visible in the viewport; descriptive errors are thrown when it is not | + +#### Usage + +```ts +// Capture an element with its scrollbar / overlay visible: +await browser.checkElement(element, 'myTag', { biDiOrigin: 'viewport' }) +await browser.saveElement(element, 'myTag', { biDiOrigin: 'viewport' }) +``` + +#### Error messages when `biDiOrigin: 'viewport'` cannot produce a valid screenshot + +**Element larger than the viewport** — must fall back to `'document'`: +``` +[BiDi viewport screenshot] The element dimensions (1400x800px) exceed the viewport (1280x720px). +You must use the default `biDiOrigin: 'document'` for this element. +Note: with `'document'` origin, composited layers such as scrollbars, fixed/sticky overlays, +and elements using `will-change` may not appear in the screenshot. +``` + +**Element not in the viewport at all** — needs scrolling: +``` +[BiDi viewport screenshot] The element is not in the viewport +(element: x=0, y=900, 300x200px; viewport: 1280x720px). +Call `element.scrollIntoView()` before taking the screenshot, or set `autoElementScroll: true`. +``` + +**Element partially outside the viewport but fits** — needs to be scrolled fully into view: +``` +[BiDi viewport screenshot] The element is not fully visible in the viewport +(element: x=-20, y=100, 300x200px; viewport: 1280x720px). +The element fits within the viewport — scroll it fully into view by calling +`element.scrollIntoView()` or setting `autoElementScroll: true`. +``` + +### Committers: 1 + +- Wim Selles ([@wswebcreation](https://github.com/wswebcreation)) diff --git a/packages/image-comparison-core/src/commands/__snapshots__/checkWebElement.test.ts.snap b/packages/image-comparison-core/src/commands/__snapshots__/checkWebElement.test.ts.snap index 921e51d4c..7a220ac96 100644 --- a/packages/image-comparison-core/src/commands/__snapshots__/checkWebElement.test.ts.snap +++ b/packages/image-comparison-core/src/commands/__snapshots__/checkWebElement.test.ts.snap @@ -92,6 +92,7 @@ exports[`checkWebElement > should execute checkWebElement with basic options 2`] }, "saveElementOptions": { "method": { + "biDiOrigin": undefined, "disableBlinkingCursor": false, "disableCSSAnimation": false, "enableLayoutTesting": false, @@ -494,6 +495,7 @@ exports[`checkWebElement > should handle custom element options 1`] = ` }, "saveElementOptions": { "method": { + "biDiOrigin": undefined, "disableBlinkingCursor": true, "disableCSSAnimation": true, "enableLayoutTesting": true, @@ -835,6 +837,7 @@ exports[`checkWebElement > should handle undefined method options with fallbacks }, "saveElementOptions": { "method": { + "biDiOrigin": undefined, "disableBlinkingCursor": false, "disableCSSAnimation": false, "enableLayoutTesting": false, diff --git a/packages/image-comparison-core/src/commands/__snapshots__/saveWebElement.test.ts.snap b/packages/image-comparison-core/src/commands/__snapshots__/saveWebElement.test.ts.snap index fe8314623..90c466b6b 100644 --- a/packages/image-comparison-core/src/commands/__snapshots__/saveWebElement.test.ts.snap +++ b/packages/image-comparison-core/src/commands/__snapshots__/saveWebElement.test.ts.snap @@ -16,6 +16,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 2, "deviceRectangles": { @@ -71,6 +72,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w }, "initialDevicePixelRatio": 2, "innerHeight": 900, + "innerWidth": undefined, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, @@ -106,6 +108,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 2, "deviceRectangles": { @@ -161,6 +164,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w }, "initialDevicePixelRatio": 2, "innerHeight": 900, + "innerWidth": 1200, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, @@ -432,6 +436,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 2, "deviceRectangles": { @@ -487,6 +492,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w }, "initialDevicePixelRatio": 2, "innerHeight": 900, + "innerWidth": undefined, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, @@ -866,6 +872,7 @@ exports[`saveWebElement > should call takeElementScreenshot with correct options { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 2, "deviceRectangles": { @@ -921,6 +928,7 @@ exports[`saveWebElement > should call takeElementScreenshot with correct options }, "initialDevicePixelRatio": 2, "innerHeight": 900, + "innerWidth": undefined, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, @@ -1170,6 +1178,7 @@ exports[`saveWebElement > should handle NaN dimension values correctly 2`] = ` { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 1, "deviceRectangles": { @@ -1225,6 +1234,7 @@ exports[`saveWebElement > should handle NaN dimension values correctly 2`] = ` }, "initialDevicePixelRatio": 1, "innerHeight": NaN, + "innerWidth": undefined, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, @@ -1524,6 +1534,7 @@ exports[`saveWebElement > should pass autoElementScroll option correctly 2`] = ` { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 2, "deviceRectangles": { @@ -1579,6 +1590,7 @@ exports[`saveWebElement > should pass autoElementScroll option correctly 2`] = ` }, "initialDevicePixelRatio": 2, "innerHeight": 900, + "innerWidth": undefined, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, @@ -1614,6 +1626,7 @@ exports[`saveWebElement > should pass resizeDimensions option correctly 2`] = ` { "addressBarShadowPadding": 6, "autoElementScroll": true, + "biDiOrigin": "document", "deviceName": "desktop", "devicePixelRatio": 2, "deviceRectangles": { @@ -1669,6 +1682,7 @@ exports[`saveWebElement > should pass resizeDimensions option correctly 2`] = ` }, "initialDevicePixelRatio": 2, "innerHeight": 900, + "innerWidth": undefined, "isAndroid": false, "isAndroidChromeDriverScreenshot": false, "isAndroidNativeWebScreenshot": false, diff --git a/packages/image-comparison-core/src/commands/checkWebElement.ts b/packages/image-comparison-core/src/commands/checkWebElement.ts index e0d1ebb14..3aa5c5654 100644 --- a/packages/image-comparison-core/src/commands/checkWebElement.ts +++ b/packages/image-comparison-core/src/commands/checkWebElement.ts @@ -24,6 +24,7 @@ export default async function checkWebElement( // 1. Extract common variables const commonCheckVariables = extractCommonCheckVariables({ folders, instanceData, wicOptions: checkElementOptions.wic }) const { + biDiOrigin, disableBlinkingCursor, disableCSSAnimation, enableLayoutTesting, @@ -40,6 +41,7 @@ export default async function checkWebElement( const saveElementOptions: SaveElementOptions = { wic: checkElementOptions.wic, method: { + biDiOrigin, disableBlinkingCursor, disableCSSAnimation, enableLayoutTesting, diff --git a/packages/image-comparison-core/src/commands/element.interfaces.ts b/packages/image-comparison-core/src/commands/element.interfaces.ts index 1a7e95f90..a38632f10 100644 --- a/packages/image-comparison-core/src/commands/element.interfaces.ts +++ b/packages/image-comparison-core/src/commands/element.interfaces.ts @@ -16,6 +16,16 @@ export interface SaveElementMethodOptions extends Partial, BaseWebScree * @default undefined */ resizeDimensions?: ResizeDimensions; + /** + * BiDi-only: which coordinate origin to use when capturing element screenshots via the BiDi protocol. + * - `'document'` (default): renders the document layout, works for any element position but + * does NOT capture composited layers (scrollbars, fixed/sticky overlays, `will-change` elements). + * - `'viewport'`: captures the composited frame as painted, which includes scrollbars and overlays, + * but requires the element to be fully visible in the viewport. Throws a descriptive error when the + * element is outside, partially outside, or larger than the viewport. + * @default 'document' + */ + biDiOrigin?: 'document' | 'viewport'; } export interface CheckElementMethodOptions extends SaveElementMethodOptions, CheckMethodOptions { } diff --git a/packages/image-comparison-core/src/commands/saveWebElement.ts b/packages/image-comparison-core/src/commands/saveWebElement.ts index 61810cb5a..569763557 100644 --- a/packages/image-comparison-core/src/commands/saveWebElement.ts +++ b/packages/image-comparison-core/src/commands/saveWebElement.ts @@ -39,6 +39,7 @@ export default async function saveWebElement( window: { devicePixelRatio, innerHeight, + innerWidth, isEmulated, isLandscape, }, @@ -52,9 +53,11 @@ export default async function saveWebElement( } = enrichedInstanceData // 3. Take the screenshot + const biDiOrigin = saveElementOptions.method.biDiOrigin ?? 'document' const elementScreenshotOptions: ElementScreenshotDataOptions = { addressBarShadowPadding, autoElementScroll, + biDiOrigin, deviceName, devicePixelRatio: devicePixelRatio || 1, deviceRectangles: instanceData.deviceRectangles, @@ -62,6 +65,7 @@ export default async function saveWebElement( isEmulated, initialDevicePixelRatio: initialDevicePixelRatio || 1, innerHeight, + innerWidth, isAndroidNativeWebScreenshot, isAndroidChromeDriverScreenshot, isAndroid, diff --git a/packages/image-comparison-core/src/methods/screenshots.interfaces.ts b/packages/image-comparison-core/src/methods/screenshots.interfaces.ts index 1d011cdb4..397458764 100644 --- a/packages/image-comparison-core/src/methods/screenshots.interfaces.ts +++ b/packages/image-comparison-core/src/methods/screenshots.interfaces.ts @@ -250,10 +250,14 @@ export interface ElementScreenshotDataOptions extends MobileCroppingOptions { /** Whether to automatically scroll the element into view. */ autoElementScroll: boolean; + /** BiDi-only: coordinate origin for element screenshots ('document' | 'viewport'). */ + biDiOrigin?: 'document' | 'viewport'; /** The element to take a screenshot of. */ element: HTMLElement | WebdriverIO.Element | ChainablePromiseElement; - /** The inner height. */ + /** The inner height of the viewport. */ innerHeight?: number; + /** The inner width of the viewport. */ + innerWidth?: number; /** Resize dimensions for the screenshot. */ resizeDimensions: any; } diff --git a/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts b/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts index 224f59e0d..4b48e4eae 100644 --- a/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts +++ b/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts @@ -149,6 +149,93 @@ describe('takeElementScreenshot', () => { }) }) + describe('BiDi viewport screenshots', () => { + const vpOptions: ElementScreenshotDataOptions = { + ...baseOptions, + biDiOrigin: 'viewport', + innerWidth: 1280, + innerHeight: 720, + } + + it('should take viewport screenshot when element is fully inside the viewport', async () => { + // element at (10, 20, 100x200) — fits in 1280x720 viewport + const result = await takeElementScreenshot(browserInstance, vpOptions, true) + + expect(result).toEqual({ + base64Image: 'bidi-screenshot-data', + isWebDriverElementScreenshot: false, + }) + expect(takeBase64BiDiScreenshotSpy).toHaveBeenCalledWith({ + browserInstance, + origin: 'viewport', + clip: { x: 10, y: 20, width: 100, height: 200 }, + }) + }) + + it('should throw when element dimensions exceed the viewport', async () => { + getElementRectMock.mockResolvedValueOnce({ x: 0, y: 0, width: 1400, height: 800 }) + + const err = await takeElementScreenshot(browserInstance, vpOptions, true).catch(e => e) as Error + expect(err.message).toMatch(/element dimensions \(1400x800px\) exceed the viewport \(1280x720px\)/) + expect(err.message).toMatch(/biDiOrigin: 'document'/) + expect(err.message).toMatch(/composited layers/) + }) + + it('should throw when element is completely outside the viewport', async () => { + // element below the fold + getElementRectMock.mockResolvedValueOnce({ x: 0, y: 800, width: 100, height: 200 }) + + const err = await takeElementScreenshot(browserInstance, vpOptions, true).catch(e => e) as Error + expect(err.message).toMatch(/element is not in the viewport/) + expect(err.message).toMatch(/scrollIntoView/) + expect(err.message).toMatch(/autoElementScroll: true/) + }) + + it('should throw when element is partially outside the viewport but fits', async () => { + // element starts at x=-10, so it bleeds left of the viewport + getElementRectMock.mockResolvedValueOnce({ x: -10, y: 0, width: 100, height: 200 }) + + const err = await takeElementScreenshot(browserInstance, vpOptions, true).catch(e => e) as Error + expect(err.message).toMatch(/not fully visible in the viewport/) + expect(err.message).toMatch(/fits within the viewport/) + expect(err.message).toMatch(/autoElementScroll: true/) + }) + + it('should include element and viewport dimensions in the error messages', async () => { + getElementRectMock.mockResolvedValueOnce({ x: 0, y: 900, width: 200, height: 100 }) + + const err = await takeElementScreenshot(browserInstance, vpOptions, true).catch(e => e) as Error + expect(err.message).toMatch(/x=0, y=900, 200x100px/) + expect(err.message).toMatch(/viewport: 1280x720px/) + }) + + it('should scroll element into view before validating when autoElementScroll is true', async () => { + const vpScrollOptions: ElementScreenshotDataOptions = { ...vpOptions, autoElementScroll: true } + // After scroll, element is fully in viewport + executeMock.mockResolvedValueOnce(300) // previous scroll position + + const result = await takeElementScreenshot(browserInstance, vpScrollOptions, true) + + expect(result.base64Image).toBe('bidi-screenshot-data') + // scrollElementIntoView + scrollToPosition (restore) + expect(executeMock).toHaveBeenCalledTimes(2) + expect(takeBase64BiDiScreenshotSpy).toHaveBeenCalledWith( + expect.objectContaining({ origin: 'viewport' }) + ) + }) + + it('should use origin: document for the default (no biDiOrigin set)', async () => { + const defaultOptions = { ...vpOptions, biDiOrigin: undefined } + + const result = await takeElementScreenshot(browserInstance, defaultOptions, true) + + expect(result.base64Image).toBe('bidi-screenshot-data') + expect(takeBase64BiDiScreenshotSpy).toHaveBeenCalledWith( + expect.objectContaining({ origin: 'document' }) + ) + }) + }) + describe('Legacy screenshots', () => { let logErrorSpy: ReturnType diff --git a/packages/image-comparison-core/src/methods/takeElementScreenshots.ts b/packages/image-comparison-core/src/methods/takeElementScreenshots.ts index a16f7e4c9..939cc3754 100644 --- a/packages/image-comparison-core/src/methods/takeElementScreenshots.ts +++ b/packages/image-comparison-core/src/methods/takeElementScreenshots.ts @@ -23,13 +23,14 @@ async function takeBiDiElementScreenshot( browserInstance: WebdriverIO.Browser, options: ElementScreenshotDataOptions ): Promise { - const isWebDriverElementScreenshot = false - // Fix #1129: scrollElementIntoView receives a promise - // The element might be a promise, so we need to resolve it before using it as a browser.execute() argument - // if we need to use it in browser.execute() const element = await (options.element as unknown as WebdriverIO.Element | Promise) + if (options.biDiOrigin === 'viewport') { + return takeBiDiElementScreenshotFromViewport(browserInstance, element, options) + } + + // Default: origin: 'document' path. // Scroll the element into the viewport so any lazy‑load / intersection // observers are triggered. We always capture from the *document* origin, // so the clip coordinates are document‑relative and independent of scroll. @@ -50,14 +51,82 @@ async function takeBiDiElementScreenshot( clip, }) - // Restore scroll position if (options.autoElementScroll && currentPosition) { await browserInstance.execute(scrollToPosition, currentPosition) } return { base64Image, - isWebDriverElementScreenshot, + isWebDriverElementScreenshot: false, + } +} + +async function takeBiDiElementScreenshotFromViewport( + browserInstance: WebdriverIO.Browser, + element: WebdriverIO.Element, + options: ElementScreenshotDataOptions +): Promise { + // Scroll element into view first so getElementRect reflects its viewport position. + let currentPosition: number | undefined + if (options.autoElementScroll) { + currentPosition = await browserInstance.execute(scrollElementIntoView as any, element, options.addressBarShadowPadding) + await waitFor(100) + } + + // getElementRect returns viewport-relative coordinates per W3C BiDi spec. + const rect = await browserInstance.getElementRect!(element.elementId) + const elX = Math.floor(rect.x) + const elY = Math.floor(rect.y) + const elW = Math.floor(rect.width) + const elH = Math.floor(rect.height) + const vpW = options.innerWidth ?? 0 + const vpH = options.innerHeight ?? 0 + + // Case 1: element larger than viewport, can never be fully captured with 'viewport' origin. + if (elW > vpW || elH > vpH) { + throw new Error( + `[BiDi viewport screenshot] The element dimensions (${elW}x${elH}px) exceed the viewport ` + + `(${vpW}x${vpH}px). You must use the default \`biDiOrigin: 'document'\` for this element. ` + + 'Note: with `\'document\'` origin, composited layers such as scrollbars, fixed/sticky overlays, ' + + 'and elements using `will-change` may not appear in the screenshot.' + ) + } + + // Case 2: element has no overlap with the viewport at all. + const isOutsideViewport = elX + elW <= 0 || elX >= vpW || elY + elH <= 0 || elY >= vpH + if (isOutsideViewport) { + throw new Error( + '[BiDi viewport screenshot] The element is not in the viewport ' + + `(element: x=${elX}, y=${elY}, ${elW}x${elH}px; viewport: ${vpW}x${vpH}px). ` + + 'Call `element.scrollIntoView()` before taking the screenshot, or set `autoElementScroll: true`.' + ) + } + + // Case 3: element is partially outside the viewport but fits, suggest scrolling it fully into view. + const isPartiallyOutside = elX < 0 || elX + elW > vpW || elY < 0 || elY + elH > vpH + if (isPartiallyOutside) { + throw new Error( + '[BiDi viewport screenshot] The element is not fully visible in the viewport ' + + `(element: x=${elX}, y=${elY}, ${elW}x${elH}px; viewport: ${vpW}x${vpH}px). ` + + 'The element fits within the viewport, scroll it fully into view by calling ' + + '`element.scrollIntoView()` or setting `autoElementScroll: true`.' + ) + } + + const clip = { x: elX, y: elY, width: elW, height: elH } + const base64Image = await takeBase64BiDiScreenshot({ + browserInstance, + origin: 'viewport', + clip, + }) + + if (options.autoElementScroll && currentPosition) { + await browserInstance.execute(scrollToPosition, currentPosition) + } + + return { + base64Image, + isWebDriverElementScreenshot: false, } }