diff --git a/.changeset/fix-await-element-before-execute.md b/.changeset/fix-await-element-before-execute.md new file mode 100644 index 000000000..438bdf320 --- /dev/null +++ b/.changeset/fix-await-element-before-execute.md @@ -0,0 +1,11 @@ +--- +"@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. + +# 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,