Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions .changeset/fix-1146-bidi-viewport-origin.md
Original file line number Diff line number Diff line change
@@ -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))
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ exports[`checkWebElement > should execute checkWebElement with basic options 2`]
},
"saveElementOptions": {
"method": {
"biDiOrigin": undefined,
"disableBlinkingCursor": false,
"disableCSSAnimation": false,
"enableLayoutTesting": false,
Expand Down Expand Up @@ -494,6 +495,7 @@ exports[`checkWebElement > should handle custom element options 1`] = `
},
"saveElementOptions": {
"method": {
"biDiOrigin": undefined,
"disableBlinkingCursor": true,
"disableCSSAnimation": true,
"enableLayoutTesting": true,
Expand Down Expand Up @@ -835,6 +837,7 @@ exports[`checkWebElement > should handle undefined method options with fallbacks
},
"saveElementOptions": {
"method": {
"biDiOrigin": undefined,
"disableBlinkingCursor": false,
"disableCSSAnimation": false,
"enableLayoutTesting": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 2,
"deviceRectangles": {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -106,6 +108,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 2,
"deviceRectangles": {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -432,6 +436,7 @@ exports[`saveWebElement > should call takeElementScreenshot with BiDi disabled w
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 2,
"deviceRectangles": {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -866,6 +872,7 @@ exports[`saveWebElement > should call takeElementScreenshot with correct options
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 2,
"deviceRectangles": {
Expand Down Expand Up @@ -921,6 +928,7 @@ exports[`saveWebElement > should call takeElementScreenshot with correct options
},
"initialDevicePixelRatio": 2,
"innerHeight": 900,
"innerWidth": undefined,
"isAndroid": false,
"isAndroidChromeDriverScreenshot": false,
"isAndroidNativeWebScreenshot": false,
Expand Down Expand Up @@ -1170,6 +1178,7 @@ exports[`saveWebElement > should handle NaN dimension values correctly 2`] = `
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 1,
"deviceRectangles": {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1524,6 +1534,7 @@ exports[`saveWebElement > should pass autoElementScroll option correctly 2`] = `
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 2,
"deviceRectangles": {
Expand Down Expand Up @@ -1579,6 +1590,7 @@ exports[`saveWebElement > should pass autoElementScroll option correctly 2`] = `
},
"initialDevicePixelRatio": 2,
"innerHeight": 900,
"innerWidth": undefined,
"isAndroid": false,
"isAndroidChromeDriverScreenshot": false,
"isAndroidNativeWebScreenshot": false,
Expand Down Expand Up @@ -1614,6 +1626,7 @@ exports[`saveWebElement > should pass resizeDimensions option correctly 2`] = `
{
"addressBarShadowPadding": 6,
"autoElementScroll": true,
"biDiOrigin": "document",
"deviceName": "desktop",
"devicePixelRatio": 2,
"deviceRectangles": {
Expand Down Expand Up @@ -1669,6 +1682,7 @@ exports[`saveWebElement > should pass resizeDimensions option correctly 2`] = `
},
"initialDevicePixelRatio": 2,
"innerHeight": 900,
"innerWidth": undefined,
"isAndroid": false,
"isAndroidChromeDriverScreenshot": false,
"isAndroidNativeWebScreenshot": false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -40,6 +41,7 @@ export default async function checkWebElement(
const saveElementOptions: SaveElementOptions = {
wic: checkElementOptions.wic,
method: {
biDiOrigin,
disableBlinkingCursor,
disableCSSAnimation,
enableLayoutTesting,
Expand Down
10 changes: 10 additions & 0 deletions packages/image-comparison-core/src/commands/element.interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export interface SaveElementMethodOptions extends Partial<Folders>, 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 { }
Expand Down
4 changes: 4 additions & 0 deletions packages/image-comparison-core/src/commands/saveWebElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export default async function saveWebElement(
window: {
devicePixelRatio,
innerHeight,
innerWidth,
isEmulated,
isLandscape,
},
Expand All @@ -52,16 +53,19 @@ 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,
element,
isEmulated,
initialDevicePixelRatio: initialDevicePixelRatio || 1,
innerHeight,
innerWidth,
isAndroidNativeWebScreenshot,
isAndroidChromeDriverScreenshot,
isAndroid,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>

Expand Down
Loading
Loading