From c49b27803e0cb747f2fb899b4287b15232c23251 Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Sat, 4 Apr 2026 07:42:12 +0200 Subject: [PATCH 1/2] fix: await ChainablePromiseElement before passing to browser.execute() in takeElementScreenshots (#1129) --- .../fix-await-element-before-execute.md | 17 +++++++++ .../src/methods/screenshots.interfaces.ts | 3 +- .../methods/takeElementScreenshots.test.ts | 35 +++++++++++++++++++ .../src/methods/takeElementScreenshots.ts | 18 +++++++--- 4 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-await-element-before-execute.md diff --git a/.changeset/fix-await-element-before-execute.md b/.changeset/fix-await-element-before-execute.md new file mode 100644 index 000000000..0b2548788 --- /dev/null +++ b/.changeset/fix-await-element-before-execute.md @@ -0,0 +1,17 @@ +--- +"@wdio/image-comparison-core": patch +--- + +## #1129 Fix `TypeError: element.getBoundingClientRect is not a function` when a `ChainablePromiseElement` is passed to `checkElement` + +When `checkElement` (or `saveElement`) was called with a `ChainablePromiseElement` — the lazy promise-based element reference that WebdriverIO's `$()` returns — the element was passed directly as an argument to `browser.execute()` without being awaited first. `browser.execute()` serializes its arguments for transfer to the browser context and cannot handle a pending Promise, so it arrived in the browser as a plain empty object `{}` instead of a WebElement reference. This caused `element.getBoundingClientRect is not a function` because the browser-side `scrollElementIntoView` script received `{}` rather than a DOM element. + +### Changes + +- **Resolve element before `browser.execute()`:** both the BiDi path (`takeBiDiElementScreenshot`) and the WebDriver path (`takeWebDriverElementScreenshot`) now await the element once at the top of the function — `await (options.element as unknown as WebdriverIO.Element | Promise)` — before it is used as a `browser.execute()` argument or passed to `takeWebElementScreenshot`. This mirrors the existing pattern already used in `saveWebElement.ts`. +- **Tighten `ElementScreenshotDataOptions.element` type:** changed from `any` to `HTMLElement | WebdriverIO.Element | ChainablePromiseElement`, making the Promise-or-resolved union explicit and consistent with the `WicElement` type used in the public-facing `InternalSaveElementMethodOptions` / `InternalCheckElementMethodOptions` interfaces. +- **New unit tests:** added two regression tests (one per screenshot path) that pass a `Promise.resolve(element)` as the element and assert that the resolved element — not the Promise — reaches `browser.execute()` and `takeWebElementScreenshot`. + +# Committers: 1 + +- Wim Selles ([@wswebcreation](https://github.com/wswebcreation)) diff --git a/packages/image-comparison-core/src/methods/screenshots.interfaces.ts b/packages/image-comparison-core/src/methods/screenshots.interfaces.ts index a2ca7436c..1d011cdb4 100644 --- a/packages/image-comparison-core/src/methods/screenshots.interfaces.ts +++ b/packages/image-comparison-core/src/methods/screenshots.interfaces.ts @@ -1,3 +1,4 @@ +import type { ChainablePromiseElement } from 'webdriverio' import type { DeviceRectangles, RectanglesOutput } from './rectangles.interfaces.js' // === UNIVERSAL BASE INTERFACES === @@ -250,7 +251,7 @@ export interface ElementScreenshotDataOptions extends /** Whether to automatically scroll the element into view. */ autoElementScroll: boolean; /** The element to take a screenshot of. */ - element: any; + element: HTMLElement | WebdriverIO.Element | ChainablePromiseElement; /** The inner height. */ innerHeight?: number; /** Resize dimensions for the screenshot. */ diff --git a/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts b/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts index 36f6711b9..224f59e0d 100644 --- a/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts +++ b/packages/image-comparison-core/src/methods/takeElementScreenshots.test.ts @@ -131,6 +131,22 @@ describe('takeElementScreenshot', () => { expect(executeMock).toHaveBeenCalledTimes(1) expect(waitForSpy).toHaveBeenCalledWith(100) }) + + it('should resolve a Promise-wrapped element (ChainablePromiseElement) before passing to browser.execute()', async () => { + const resolvedElement = { elementId: 'promise-element' } + const optionsWithPromiseElement = { + ...baseOptions, + autoElementScroll: true, + element: Promise.resolve(resolvedElement) as any, + } + executeMock.mockResolvedValueOnce(100) + + await takeElementScreenshot(browserInstance, optionsWithPromiseElement, true) + + expect(getElementRectMock).toHaveBeenCalledWith('promise-element') + // The resolved element (not the Promise) must be passed to browser.execute() + expect(executeMock.mock.calls[0][1]).toEqual(resolvedElement) + }) }) describe('Legacy screenshots', () => { @@ -212,6 +228,25 @@ describe('takeElementScreenshot', () => { expect(waitForSpy).toHaveBeenCalledWith(100) }) + it('should resolve a Promise-wrapped element (ChainablePromiseElement) before passing to browser.execute()', async () => { + const resolvedElement = { elementId: 'promise-element' } + const optionsWithPromiseElement = { + ...baseOptions, + autoElementScroll: true, + element: Promise.resolve(resolvedElement) as any, + } + executeMock.mockResolvedValueOnce(100) + + await takeElementScreenshot(browserInstance, optionsWithPromiseElement, false) + + // The resolved element (not the Promise) must be passed to browser.execute() + expect(executeMock.mock.calls[0][1]).toEqual(resolvedElement) + // And also passed to takeWebElementScreenshot + expect(takeWebElementScreenshotSpy).toHaveBeenCalledWith( + expect.objectContaining({ element: resolvedElement }) + ) + }) + it('should enable fallback when resizeDimensions is provided', async () => { const optionsWithResize = { ...baseOptions, diff --git a/packages/image-comparison-core/src/methods/takeElementScreenshots.ts b/packages/image-comparison-core/src/methods/takeElementScreenshots.ts index 0e4de5e8a..a16f7e4c9 100644 --- a/packages/image-comparison-core/src/methods/takeElementScreenshots.ts +++ b/packages/image-comparison-core/src/methods/takeElementScreenshots.ts @@ -25,19 +25,24 @@ async function takeBiDiElementScreenshot( ): 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) + // 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. let currentPosition: number | undefined if (options.autoElementScroll) { - currentPosition = await browserInstance.execute(scrollElementIntoView as any, options.element, options.addressBarShadowPadding) + currentPosition = await browserInstance.execute(scrollElementIntoView as any, element, options.addressBarShadowPadding) await waitFor(100) } // Get the element rect and clip the screenshot. WebDriver getElementRect // returns coordinates relative to the document origin, which matches the // BiDi `origin: 'document'` coordinate system. - const rect = await browserInstance.getElementRect!((await options.element as WebdriverIO.Element).elementId) + const rect = await browserInstance.getElementRect!(element.elementId) const clip = { x: Math.floor(rect.x), y: Math.floor(rect.y), width: Math.floor(rect.width), height: Math.floor(rect.height) } const base64Image = await takeBase64BiDiScreenshot({ browserInstance, @@ -63,10 +68,15 @@ async function takeWebDriverElementScreenshot( let base64Image: string let 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) + // Scroll the element into top of the viewport and return the current scroll position let currentPosition: number | undefined if (options.autoElementScroll) { - currentPosition = await browserInstance.execute(scrollElementIntoView as any, options.element, options.addressBarShadowPadding) + currentPosition = await browserInstance.execute(scrollElementIntoView as any, element, options.addressBarShadowPadding) // We need to wait for the scroll to finish before taking the screenshot await waitFor(100) } @@ -77,7 +87,7 @@ async function takeWebDriverElementScreenshot( browserInstance, devicePixelRatio: options.devicePixelRatio, deviceRectangles: options.deviceRectangles, - element: options.element, + element, initialDevicePixelRatio: options.initialDevicePixelRatio, isEmulated: options.isEmulated, innerHeight: options.innerHeight, From 3126a75ecb32d9014ac7e6c520f6794df367384a Mon Sep 17 00:00:00 2001 From: wswebcreation Date: Sat, 4 Apr 2026 07:42:30 +0200 Subject: [PATCH 2/2] chore: add changeset --- .changeset/fix-await-element-before-execute.md | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.changeset/fix-await-element-before-execute.md b/.changeset/fix-await-element-before-execute.md index 0b2548788..438bdf320 100644 --- a/.changeset/fix-await-element-before-execute.md +++ b/.changeset/fix-await-element-before-execute.md @@ -4,13 +4,7 @@ ## #1129 Fix `TypeError: element.getBoundingClientRect is not a function` when a `ChainablePromiseElement` is passed to `checkElement` -When `checkElement` (or `saveElement`) was called with a `ChainablePromiseElement` — the lazy promise-based element reference that WebdriverIO's `$()` returns — the element was passed directly as an argument to `browser.execute()` without being awaited first. `browser.execute()` serializes its arguments for transfer to the browser context and cannot handle a pending Promise, so it arrived in the browser as a plain empty object `{}` instead of a WebElement reference. This caused `element.getBoundingClientRect is not a function` because the browser-side `scrollElementIntoView` script received `{}` rather than a DOM element. - -### Changes - -- **Resolve element before `browser.execute()`:** both the BiDi path (`takeBiDiElementScreenshot`) and the WebDriver path (`takeWebDriverElementScreenshot`) now await the element once at the top of the function — `await (options.element as unknown as WebdriverIO.Element | Promise)` — before it is used as a `browser.execute()` argument or passed to `takeWebElementScreenshot`. This mirrors the existing pattern already used in `saveWebElement.ts`. -- **Tighten `ElementScreenshotDataOptions.element` type:** changed from `any` to `HTMLElement | WebdriverIO.Element | ChainablePromiseElement`, making the Promise-or-resolved union explicit and consistent with the `WicElement` type used in the public-facing `InternalSaveElementMethodOptions` / `InternalCheckElementMethodOptions` interfaces. -- **New unit tests:** added two regression tests (one per screenshot path) that pass a `Promise.resolve(element)` as the element and assert that the resolved element — not the Promise — reaches `browser.execute()` and `takeWebElementScreenshot`. +When `checkElement` (or `saveElement`) was called with a `ChainablePromiseElement`, the lazy promise-based element reference that WebdriverIO's `$()` returns, the element was passed directly as an argument to `browser.execute()` without being awaited first. `browser.execute()` serializes its arguments for transfer to the browser context and cannot handle a pending Promise, so it arrived in the browser as a plain empty object `{}` instead of a WebElement reference. This caused `element.getBoundingClientRect is not a function` because the browser-side `scrollElementIntoView` script received `{}` rather than a DOM element. # Committers: 1