From a438c1f4b12df21bf9f4eb46181962a3f8b348c4 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 21:38:52 -0400 Subject: [PATCH 01/11] feat: read the WebdriverIO v10 wdio.kind brand WebdriverIO v10 brands its browsers, browsing contexts, elements, element lists and mocks with `Symbol.for('wdio.kind')`, and a not-awaited `$()` with `Symbol.for('wdio.chainable')`. `getWdioKind()` and `isChainable()` read them without a dependency on `@wdio/utils`. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- src/util/wdioKind.ts | 23 ++++++++++++++++++ test/util/wdioKind.test.ts | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/util/wdioKind.ts create mode 100644 test/util/wdioKind.test.ts diff --git a/src/util/wdioKind.ts b/src/util/wdioKind.ts new file mode 100644 index 000000000..e9ca41d7c --- /dev/null +++ b/src/util/wdioKind.ts @@ -0,0 +1,23 @@ +// `Symbol.for()` gives the symbols of `@wdio/utils` without a dependency on it, also with two copies of `webdriverio` +export const WDIO_KIND = Symbol.for('wdio.kind') +export const WDIO_CHAINABLE = Symbol.for('wdio.chainable') + +export type WdioKind = 'browser' | 'element' | 'element-array' | 'mock' | 'browsing-context' + +const WDIO_KINDS: readonly unknown[] = ['browser', 'element', 'element-array', 'mock', 'browsing-context'] satisfies WdioKind[] + +/** + * The role that WebdriverIO v10 brands its objects with. A copy, such as `[...elements]`, has no brand. + * Reads the property without `in`: a Proxy with only a `get` trap still gives it. + */ +export const getWdioKind = (value: unknown): WdioKind | undefined => { + if (!value || (typeof value !== 'object' && typeof value !== 'function')) { + return undefined + } + const kind = (value as { [WDIO_KIND]?: unknown })[WDIO_KIND] + return WDIO_KINDS.includes(kind) ? kind as WdioKind : undefined +} + +/** A not-awaited `$()`: the brand says `element`, but it is a Promise of the element */ +export const isChainable = (value: unknown): boolean => + (value as { [WDIO_CHAINABLE]?: unknown } | null | undefined)?.[WDIO_CHAINABLE] === true diff --git a/test/util/wdioKind.test.ts b/test/util/wdioKind.test.ts new file mode 100644 index 000000000..9f758bbfc --- /dev/null +++ b/test/util/wdioKind.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from 'vitest' + +import { getWdioKind, isChainable } from '../../src/util/wdioKind.js' + +describe(getWdioKind, () => { + test.each(['browser', 'element', 'element-array', 'mock', 'browsing-context'])('returns the %s kind', (kind) => { + expect(getWdioKind({ [Symbol.for('wdio.kind')]: kind })).toBe(kind) + }) + + test.each([ + ['undefined', undefined], + ['null', null], + ['a string', 'element'], + ['a number', 42], + ['an object without brand', {}], + ['an array without brand', []], + ['an unknown kind', { [Symbol.for('wdio.kind')]: 'window' }], + ['a local symbol with the same description', { [Symbol('wdio.kind')]: 'element' }], + ])('returns undefined for %s', (_, value) => { + expect(getWdioKind(value)).toBeUndefined() + }) + + test('reads the brand through a Proxy of a function with only a get trap, like the @wdio/globals browser', () => { + const browser = new Proxy(class Browser {}, { get: (_, prop) => prop === Symbol.for('wdio.kind') ? 'browser' : undefined }) + + expect(getWdioKind(browser)).toBe('browser') + }) + + test('reads the brand of an array', () => { + const elements = Object.defineProperty([], Symbol.for('wdio.kind'), { value: 'element-array' }) + + expect(getWdioKind(elements)).toBe('element-array') + }) +}) + +describe(isChainable, () => { + test('is true for a not-awaited `$()`', () => { + expect(isChainable({ [Symbol.for('wdio.chainable')]: true })).toBe(true) + }) + + test.each([ + ['an object without the flag', {}], + ['null', null], + ['a flag that is not `true`', { [Symbol.for('wdio.chainable')]: 'true' }], + ])('is false for %s', (_, value) => { + expect(isChainable(value)).toBe(false) + }) +}) From 0ec685b2eeb152a972b2e5ad41744d7d16cd17b8 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 21:44:49 -0400 Subject: [PATCH 02/11] test: brand the WebdriverIO mocks with wdio.kind like WebdriverIO 10.0.0 The mocks of the browser, the elements, the element lists and the network mocks now have the `wdio.kind` brand, and a not-awaited `$()` has `wdio.chainable`, as in WebdriverIO 10.0.0. The not-awaited `$$()` mock was a Proxy of a Promise, as in v9. In v10 it is the element list itself: `Array.isArray()` is true, `'getElements' in` is true, it has `then`, `catch` and `finally` until it is awaited, its `length` is a Promise until then, and awaiting it gives the same list. The tests that expected the v9 shape now expect the v10 one, so the unit tests take the path that production takes. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- test/__mocks__/@wdio/globals.ts | 74 +++++++++++++------ test/globals_mock.test.ts | 12 ++- test/matchers/element/toHaveSize.test.ts | 3 +- .../elements/toBeElementsArrayOfSize.test.ts | 4 +- test/matchers/mock/toBeRequested.test.ts | 3 +- test/matchers/mock/toBeRequestedTimes.test.ts | 3 +- test/matchers/mock/toBeRequestedWith.test.ts | 3 +- test/util/elementsUtil.test.ts | 7 +- test/util/multiRemoteUtils.test.ts | 6 +- 9 files changed, 77 insertions(+), 38 deletions(-) diff --git a/test/__mocks__/@wdio/globals.ts b/test/__mocks__/@wdio/globals.ts index 945c75292..6aa3123d0 100644 --- a/test/__mocks__/@wdio/globals.ts +++ b/test/__mocks__/@wdio/globals.ts @@ -6,6 +6,11 @@ import { vi } from 'vitest' import type { ChainablePromiseArray, ChainablePromiseElement, ParsedCSSValue } from 'webdriverio' import { Size } from '../../../src/matchers/element/toHaveSize' import type { WdioMultiRemoteElementArray, WdioMultiRemoteMock } from '../../../src/types' +import { WDIO_CHAINABLE, WDIO_KIND, type WdioKind } from '../../../src/util/wdioKind' + +/** Brands a mock like WebdriverIO v10 `setWdioKind()`: a non-enumerable `Symbol.for('wdio.kind')`, so a copy has no brand */ +export const setWdioKind = (target: T, kind: WdioKind): T => + Object.defineProperty(target, WDIO_KIND, { value: kind, configurable: true }) const getElementMethods = () => ({ isDisplayed: vi.spyOn({ isDisplayed: async () => true }, 'isDisplayed'), @@ -44,7 +49,7 @@ export const elementWithoutSelectorFactory = (index?: number, parent: WebdriverI parent } satisfies Partial - const element = partialElement as unknown as WebdriverIO.Element + const element = setWdioKind(partialElement, 'element') as unknown as WebdriverIO.Element element.getElement = vi.fn().mockResolvedValue(element) // Note: an element found has element.elementId while a not found has element.error @@ -63,7 +68,7 @@ export const elementFactory = (selector: string, index?: number, parent: Webdriv parent } satisfies Partial - const element = partialElement as unknown as WebdriverIO.Element + const element = setWdioKind(partialElement, 'element') as unknown as WebdriverIO.Element element.getElement = vi.fn().mockResolvedValue(element) // Note: an element found has element.elementId while a not found has element.error @@ -82,7 +87,7 @@ export const notFoundElementFactory = (_selector: string, index?: number, parent parent } satisfies Partial - const element = partialElement as unknown as WebdriverIO.Element + const element = setWdioKind(partialElement, 'element') as unknown as WebdriverIO.Element // Note: an element found has element.elementId while a not found has element.error const elementId = `${_selector}${index ? '-' + index : ''}` @@ -124,6 +129,10 @@ export const $Factory = (element: WebdriverIO.Element, findDelay = 0): Chainable // Ensure `'getElement' in chainableElement` at runtime does not exist while allowing to use `await chainableElement.getElement()` const runtimeChainableElement = new Proxy(chainablePromiseElement, { get(target, prop) { + // Like WebdriverIO v10: a not-awaited `$()` has the `element` brand (read below from the element) and is chainable + if (prop === WDIO_CHAINABLE) { + return true + } if (prop in element) { const originalValue = element[prop as keyof WebdriverIO.Element] @@ -179,7 +188,7 @@ export const $$ = vi.fn((selector: Parameters[0]) => export function elementArrayFactory(selector: string, length: number = 2, parent: WebdriverIO.Browser | WebdriverIO.Element = browserFactory(length)): WebdriverIO.ElementArray { const elements: WebdriverIO.Element[] = Array(length).fill(null).map((_, index) => elementFactory(selector, index)) - const elementArray = elements as unknown as WebdriverIO.ElementArray + const elementArray = setWdioKind(elements, 'element-array') as unknown as WebdriverIO.ElementArray elementArray.foundWith = '$$' elementArray.props = [] @@ -194,20 +203,37 @@ export function elementArrayFactory(selector: string, length: number = 2, parent return elementArray } +/** + * Mocks a not-awaited `$$()` like WebdriverIO v10: it is the element list itself, not a Promise. Until it is awaited, + * it has `then`, `catch` and `finally`, and its `length` is a Promise. Awaiting it resolves to the same list, without `then`. + */ export function chainableElementArrayFactory(selector: string, length: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browserFactory()): ChainablePromiseArray { const elementArray = elementArrayFactory(selector, length, parent) + let resolved = false + const settle = () => { + resolved = true + return Promise.resolve(runtimeChainablePromiseArray as unknown as WebdriverIO.ElementArray) + } - // Wdio framework does return a Promise-wrapped element, so we need to mimic this behavior - const chainablePromiseArray = Promise.resolve(elementArray) as unknown as ChainablePromiseArray - - // Ensure `'getElements' in chainableElements` is false while allowing to use `await chainableElement.getElements()` - const runtimeChainablePromiseArray = new Proxy(chainablePromiseArray, { - get(target, prop) { + const runtimeChainablePromiseArray: ChainablePromiseArray = new Proxy(elementArray, { + get(target, prop, receiver) { + if (!resolved) { + if (prop === 'then') { + return (onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) => settle().then(onFulfilled, onRejected) + } + if (prop === 'catch' || prop === 'finally') { + return (handler: () => unknown) => settle()[prop](handler) + } + if (prop === 'length') { + return settle().then(() => target.length) + } + } if (typeof prop === 'string' && /^\d+$/.test(prop)) { // Simulate index out of bounds error when asking for an element outside the array length const index = parseInt(prop, 10) - if (index >= length) { - const error = new Error(`Index out of bounds! $$(${selector}) returned only ${length} elements.`) + // The current length: a refetch writes the new elements into the same list + if (index >= target.length) { + const error = new Error(`Index out of bounds! $$(${selector}) returned only ${target.length} elements.`) return new Proxy(Promise.resolve(), { get(_target, prop) { if (prop === 'then') { @@ -218,12 +244,12 @@ export function chainableElementArrayFactory(selector: string, length: number, p }) } } - if (elementArray && prop in elementArray) { - return elementArray[prop as keyof WebdriverIO.ElementArray] - } - const value = Reflect.get(target, prop) - return typeof value === 'function' ? value.bind(target) : value + return Reflect.get(target, prop, receiver) } + }) as unknown as ChainablePromiseArray + elementArray.getElements = vi.fn().mockImplementation(async () => { + await settle() + return runtimeChainablePromiseArray }) elementArray.parent.$$ = vi.fn().mockImplementation((selector: string) => { @@ -258,6 +284,8 @@ export class Browser { } } +setWdioKind(Browser.prototype, 'browser') + export const browserFactory = (elementArrayLength = 2): WebdriverIO.Browser => { return new Browser(elementArrayLength) as unknown as WebdriverIO.Browser } @@ -341,6 +369,8 @@ export class CustomMultiRemoteDriver { } } +setWdioKind(CustomMultiRemoteDriver.prototype, 'browser') + export const multiRemoteBrowserFactory = ( browsers?: Record ): WebdriverIO.MultiRemoteBrowser => { @@ -412,7 +442,7 @@ const buildMultiRemoteElementWrapper = ( ), } satisfies Partial & { isMultiRemote: true } as unknown as WebdriverIO.MultiRemoteElement - return multiRemoteElement + return setWdioKind(multiRemoteElement, 'element') } export function createMultiRemoteElementMock( @@ -449,7 +479,7 @@ export function createMultiRemoteElementArrayMock( buildMultiRemoteElementWrapper(instances, instanceElementArrays.map((elements) => elements[index]), selector) ) - const elementArray = wrapped as unknown as WdioMultiRemoteElementArray & { isMultiRemote: true } + const elementArray = setWdioKind(wrapped, 'element-array') as unknown as WdioMultiRemoteElementArray & { isMultiRemote: true } elementArray.isMultiRemote = true elementArray.selector = selector elementArray.foundWith = '$$' @@ -467,8 +497,8 @@ export function createMultiRemoteElementArrayMock( } /** Mocks a WebdriverIO v10 multi-remote `mock()`: a `MultiRemoteMock` with one mock per instance name */ -export const multiRemoteMockFactory = (mocks: Record): WdioMultiRemoteMock => ({ - isMultiRemote: true, +export const multiRemoteMockFactory = (mocks: Record): WdioMultiRemoteMock => setWdioKind({ + isMultiRemote: true as const, instances: Object.keys(mocks), getInstance: (name: string) => mocks[name] -}) +}, 'mock') diff --git a/test/globals_mock.test.ts b/test/globals_mock.test.ts index da99de933..53bf0824d 100644 --- a/test/globals_mock.test.ts +++ b/test/globals_mock.test.ts @@ -61,10 +61,14 @@ describe('globals mock', () => { }) describe($$, () => { - it('should return a ChainablePromiseArray', async () => { + it('should return the element list, with `then` until it is awaited, like WebdriverIO v10', async () => { const els = $$('foo') - expect(els).toHaveProperty('then') expect(typeof els.then).toBe('function') + expect(els).not.toBeInstanceOf(Promise) + expect(Array.isArray(els)).toBe(true) + + expect(await els).toBe(els) + expect(els.then).toBeUndefined() }) it('should resolve to an element array', async () => { @@ -82,8 +86,8 @@ describe('globals mock', () => { it('should allow calling getElements on the chainable promise', async () => { const chainable = $$('foo') - // 'getElements' should not be present in the chainable object if checked via `in` - expect('getElements' in chainable).toBe(false) + // Like WebdriverIO v10, which keeps `'getElements' in` true on a not-awaited `$$()` + expect('getElements' in chainable).toBe(true) // But it should be callable const els = await chainable.getElements() diff --git a/test/matchers/element/toHaveSize.test.ts b/test/matchers/element/toHaveSize.test.ts index c80624f66..c65445645 100644 --- a/test/matchers/element/toHaveSize.test.ts +++ b/test/matchers/element/toHaveSize.test.ts @@ -170,7 +170,8 @@ Received: {"height": 32, "width": 32}` beforeEach(async () => { els = elements - awaitedEls = Array.isArray(els) ? els : await els + // WebdriverIO v10: a not-awaited `$$()` is also an array, so await it to read its length + awaitedEls = await els awaitedEls.forEach((el) => { vi.mocked(el.getSize).mockResolvedValue(expectedValue as unknown as Size & number) }) diff --git a/test/matchers/elements/toBeElementsArrayOfSize.test.ts b/test/matchers/elements/toBeElementsArrayOfSize.test.ts index ad45bd669..c152f899a 100644 --- a/test/matchers/elements/toBeElementsArrayOfSize.test.ts +++ b/test/matchers/elements/toBeElementsArrayOfSize.test.ts @@ -241,7 +241,7 @@ Received : 2` expect(refetchElements).toHaveBeenNthCalledWith(3, elementArrayOf5) }) - test('refresh once but does not update actual elements since they are not of type ElementArray or Element[]', async () => { + test('refresh once and update in place a not-awaited $$(), which is the element list itself in WebdriverIO v10', async () => { vi.mocked(browser.$$) .mockResolvedValueOnce(elementArrayOf2) .mockResolvedValue(elementArrayOf5) @@ -249,7 +249,7 @@ Received : 2` const result = await thisContext.toBeElementsArrayOfSize(nonAwaitedElements, 5, { wait: 500 }) expect(result.pass).toBe(true) - expect(nonAwaitedElements).toBeInstanceOf(Promise) + expect(nonAwaitedElements).not.toBeInstanceOf(Promise) expect((await nonAwaitedElements).length).toBe(5) expect(await nonAwaitedElements).toBe(elements) // Original actual elements array but altered expect(browser.$$).toHaveBeenCalledTimes(2) diff --git a/test/matchers/mock/toBeRequested.test.ts b/test/matchers/mock/toBeRequested.test.ts index 9f1975058..64c8367ea 100644 --- a/test/matchers/mock/toBeRequested.test.ts +++ b/test/matchers/mock/toBeRequested.test.ts @@ -4,7 +4,7 @@ import type { Matches, Mock } from 'webdriverio' import { toBeRequested } from '../../../src/matchers/mock/toBeRequested.js' import stripAnsi from 'strip-ansi' -import { multiRemoteBrowserFactory } from '../../__mocks__/@wdio/globals.js' +import { multiRemoteBrowserFactory, setWdioKind } from '../../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') class TestMock implements Mock { @@ -25,6 +25,7 @@ class TestMock implements Mock { restore () { return Promise.resolve() } waitForResponse () { return Promise.resolve(true) } } +setWdioKind(TestMock.prototype, 'mock') const mockMatch: Matches = { body: 'foo', diff --git a/test/matchers/mock/toBeRequestedTimes.test.ts b/test/matchers/mock/toBeRequestedTimes.test.ts index e3a94c5af..164ef5740 100644 --- a/test/matchers/mock/toBeRequestedTimes.test.ts +++ b/test/matchers/mock/toBeRequestedTimes.test.ts @@ -5,7 +5,7 @@ import type { Matches, Mock } from 'webdriverio' import { toBeRequestedTimes } from '../../../src/matchers/mock/toBeRequestedTimes.js' import stripAnsi from 'strip-ansi' import { waitUntil } from '../../../src/util/waitUntil.js' -import { multiRemoteBrowserFactory, multiRemoteMockFactory } from '../../__mocks__/@wdio/globals.js' +import { multiRemoteBrowserFactory, multiRemoteMockFactory, setWdioKind } from '../../__mocks__/@wdio/globals.js' class TestMock implements Mock { _calls: Matches[] @@ -25,6 +25,7 @@ class TestMock implements Mock { restore () { return Promise.resolve() } waitForResponse () { return Promise.resolve(true) } } +setWdioKind(TestMock.prototype, 'mock') const mockMatch: Matches = { body: 'foo', diff --git a/test/matchers/mock/toBeRequestedWith.test.ts b/test/matchers/mock/toBeRequestedWith.test.ts index 89261b45a..0be63b59f 100644 --- a/test/matchers/mock/toBeRequestedWith.test.ts +++ b/test/matchers/mock/toBeRequestedWith.test.ts @@ -4,7 +4,7 @@ import { toBeRequestedWith } from '../../../src/matchers/mock/toBeRequestedWith. import type { local } from 'webdriver' import { jasmine } from '../../__mocks__/jasmine.js' import stripAnsi from 'strip-ansi' -import { multiRemoteBrowserFactory, multiRemoteMockFactory } from '../../__mocks__/@wdio/globals.js' +import { multiRemoteBrowserFactory, multiRemoteMockFactory, setWdioKind } from '../../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') @@ -29,6 +29,7 @@ class TestMock { return this._calls } } +setWdioKind(TestMock.prototype, 'mock') function reduceHeaders(headers: local.NetworkHeader[]) { return Object.entries(headers).reduce((acc, [, value]: [string, local.NetworkHeader]) => { diff --git a/test/util/elementsUtil.test.ts b/test/util/elementsUtil.test.ts index 03acdda78..d94c7cb01 100644 --- a/test/util/elementsUtil.test.ts +++ b/test/util/elementsUtil.test.ts @@ -272,6 +272,8 @@ describe('elementsUtil', () => { test.for([ await $$('elements').getElements(), await $$('elements'), + // WebdriverIO v10: a not-awaited `$$()` is the element list itself, not a Promise + $$('elements'), elementArrayFactory('elements'), await chainableElementArrayFactory('elements', 3), ])('should return true for ElementArray: %s', async (elements) => { @@ -285,7 +287,6 @@ describe('elementsUtil', () => { test.for([ await $('elements'), await $('elements').getElement(), - $$('elements'), $$('elements').getElements(), elementFactory('element'), [elementFactory('element1'), elementFactory('element2')], @@ -339,6 +340,7 @@ describe('elementsUtil', () => { await $$('elements'), elementArrayFactory('elements'), await chainableElementArrayFactory('elements', 3), + $$('elements'), [elementFactory('element1'), elementFactory('element2')], [] ])('should return true for ElementArray or Element[] %s', async (elements) => { @@ -350,7 +352,6 @@ describe('elementsUtil', () => { test.for([ await $('elements'), await $('elements').getElement(), - $$('elements'), $$('elements').getElements(), undefined, null, @@ -377,6 +378,7 @@ describe('elementsUtil', () => { await $$('elements'), elementArrayFactory('elements'), await chainableElementArrayFactory('elements', 3), + $$('elements'), [elementFactory('element1'), elementFactory('element2')], [] ])('should return true for Element or ElementArray or Element[]: %s', async (element) => { @@ -386,7 +388,6 @@ describe('elementsUtil', () => { }) test.for([ - $$('elements'), $$('elements').getElements(), $('element'), $('element').getElement(), diff --git a/test/util/multiRemoteUtils.test.ts b/test/util/multiRemoteUtils.test.ts index e21672a3f..0187bb9f4 100644 --- a/test/util/multiRemoteUtils.test.ts +++ b/test/util/multiRemoteUtils.test.ts @@ -2,7 +2,7 @@ import { vi, test, describe, expect, afterEach } from 'vitest' import { getElementsPerInstance, getGlobalMultiRemoteInstanceNames, getPerInstanceValues, getMockInstanceNames, hasMultiRemoteFlag, hasSameInstanceNames, isBrowser, isMockArray, isMultiRemoteMatcher, isMultiRemoteValues } from '../../src/util/multiRemoteUtils.js' import { multiRemote } from '../../src/api/index.js' -import { browserFactory, createMultiRemoteElementArrayMock, multiRemoteBrowserFactory } from '../__mocks__/@wdio/globals.js' +import { browserFactory, createMultiRemoteElementArrayMock, multiRemoteBrowserFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') @@ -94,7 +94,7 @@ describe('multiRemoteUtils', () => { }) describe(isMockArray, () => { - const mock = () => ({ calls: [] }) as unknown as WebdriverIO.Mock + const mock = () => setWdioKind({ calls: [] }, 'mock') as unknown as WebdriverIO.Mock test('is true for a non-empty array of mocks', () => { expect(isMockArray([mock(), mock()])).toBe(true) @@ -106,7 +106,7 @@ describe('multiRemoteUtils', () => { }) describe(getMockInstanceNames, () => { - const mocks = (length: number) => Array.from({ length }, () => ({ calls: [] }) as unknown as WebdriverIO.Mock) + const mocks = (length: number) => Array.from({ length }, () => setWdioKind({ calls: [] }, 'mock') as unknown as WebdriverIO.Mock) afterEach(() => { vi.unstubAllGlobals() From 1c3ae5823c2debe86d512bf52ffb71bfa3a521fa Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 21:46:46 -0400 Subject: [PATCH 03/11] refactor!: identify elements and element lists by the wdio.kind brand `isElement()`, `isStrictlyElementArray()`, `isMultiRemoteElement()` and `isMultiRemoteElementArray()` now read the WebdriverIO v10 `wdio.kind` brand, not the shape of the object (`parent`, `foundWith`, `getElement`, `getElements`, `selector`). A frame browsing context also has a `parent`, and a fake with the same properties is not a WebdriverIO object. A not-awaited `$()` has the `element` brand, but it is a Promise, so `isElement()` also checks `wdio.chainable`. The failure message walks the parents of an element while they are awaited elements or lists, by their brand. An unbranded copy of a list (`[...elements]`, `elements.map()`) is still an `Element[]`, because its items have the brand. BREAKING CHANGE: an object without the WebdriverIO v10 `wdio.kind` brand is not an element or an element list. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- src/util/elementsUtil.ts | 52 ++++++++++++++------------------- src/util/formatMessage.ts | 9 +++++- test/util/elementsUtil.test.ts | 47 +++++++++++++++++++++++++++-- test/util/formatMessage.test.ts | 10 +++++++ 4 files changed, 84 insertions(+), 34 deletions(-) diff --git a/src/util/elementsUtil.ts b/src/util/elementsUtil.ts index 6b8feb6fa..5cccdd2b5 100644 --- a/src/util/elementsUtil.ts +++ b/src/util/elementsUtil.ts @@ -1,5 +1,6 @@ import { isArrayContainingMatcher } from '../utils.js' import { hasMultiRemoteFlag } from './multiRemoteUtils.js' +import { getWdioKind, isChainable } from './wdioKind.js' import type { MaybeSomeWdioElementOrArrayMaybePromiseOrMultiRemoteElements, WdioElements, WdioElementsMaybePromise, WdioMultiRemoteElementArray, WdioMultiRemoteElements } from '../types.js' /** @@ -33,32 +34,22 @@ export const isArray = (obj: unknown): obj is unknown[] | WebdriverIO.ElementArr return Array.isArray(obj) } -const isSelector = (obj: unknown): obj is WebdriverIO.ElementArray | WebdriverIO.Element => { - // WARNING: selector can be undefined, so it is unreliable to check for it. - return !!obj - && typeof obj === 'object' - && 'parent' in obj -} - -export const isElementArray = (obj: unknown): obj is WebdriverIO.ElementArray => { - return isSelector(obj) - && 'foundWith' in obj - && !isMultiRemote(obj) // Ensure multi-remote elements are excluded -} - +/** + * A `$$()` list, awaited or not: in WebdriverIO v10, a not-awaited `$$()` is the list itself, not a Promise. + */ export const isStrictlyElementArray = (obj: unknown): obj is WebdriverIO.ElementArray => { - return isElementArray(obj) + return getWdioKind(obj) === 'element-array' && Array.isArray(obj) - && 'getElements' in obj // specific to ElementArray - && !isMultiRemote(obj) // Ensure multi-remote elements are excluded + && !isMultiRemote(obj) } +/** + * An awaited `$()`: a not-awaited `$()` has the same brand, but it is a Promise of the element. + */ export const isElement = (obj: unknown): obj is WebdriverIO.Element => { - // Note: elementId is only for found element - return isSelector(obj) - && !Array.isArray(obj) - && 'getElement' in obj // specific to Element - && !isMultiRemote(obj) // Ensure multi-remote elements are excluded + return getWdioKind(obj) === 'element' + && !isChainable(obj) + && !isMultiRemote(obj) } /** @@ -142,12 +133,12 @@ export const awaitElementOrArray = async( } // for `await $()` or `WebdriverIO.Element` - if ('getElement' in awaitedElements) { + if (isElement(awaitedElements)) { const element = await (awaitedElements as WebdriverIO.Element).getElement() return { selector: element, element } } - // for `await $$()` or `WebdriverIO.ElementArray` but not `WebdriverIO.Element[]` - if ('getElements' in awaitedElements) { + // for `$$()`, awaited or not, or `WebdriverIO.ElementArray` but not `WebdriverIO.Element[]` + if (isStrictlyElementArray(awaitedElements)) { const elements = await awaitedElements.getElements() return { selector: elements, elements, isEmptyElements: elements.length === 0 } } @@ -169,8 +160,8 @@ export const awaitElementArray = async(received: WdioElementsMaybePromise | unde return { other: awaitedElements as any } } - // for `await $$()` or `WebdriverIO.ElementArray` but not `WebdriverIO.Element[]` - if ('getElements' in awaitedElements) { + // for `$$()`, awaited or not, or `WebdriverIO.ElementArray` but not `WebdriverIO.Element[]` + if (isStrictlyElementArray(awaitedElements)) { return { elements: await awaitedElements.getElements() } } @@ -182,17 +173,18 @@ const isMultiRemote = (obj: unknown): obj is WebdriverIO.MultiRemoteElement | Wd return hasMultiRemoteFlag(obj) } +/** + * An awaited multi-remote `$()`, or an item of a multi-remote `$$()`. It has no `parent`. + */ export const isMultiRemoteElement = (obj: unknown): obj is WebdriverIO.MultiRemoteElement => { - // `selector` distinguishes a MultiRemoteElement from a MultiRemoteBrowser (both share the multi-remote flag and `getInstance`, - // only the element has a `selector`); the array check excludes WdioMultiRemoteElementArray. - return isMultiRemote(obj) && !Array.isArray(obj) && 'selector' in obj + return getWdioKind(obj) === 'element' && !isChainable(obj) && isMultiRemote(obj) } /** * The `MultiRemoteElementArray` of a multi-remote `$$()`, which knows its parent, its selector and its instances. */ export const isMultiRemoteElementArray = (obj: unknown): obj is WdioMultiRemoteElementArray => { - return hasMultiRemoteFlag(obj) && 'parent' in (obj as object) && 'foundWith' in (obj as object) && 'selector' in (obj as object) + return getWdioKind(obj) === 'element-array' && isMultiRemote(obj) } /** diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index da863725c..b2c2c28e9 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -3,6 +3,7 @@ import { equals } from '../jasmineUtils.js' import type { MultiRemoteValuesWithArray, WdioElements, WdioMultiRemoteElements } from '../types.js' import { isArrayOfElement, isElementArrayLike, isElementOrArrayLike, isElementOrArrayOrMultiRemoteElementLike, isMultiRemoteElement, isMultiRemoteElementArray, isMultiRemoteElementLike, isStrictlyElementArray } from './elementsUtil.js' import { toJsonString } from './stringUtil.js' +import { getWdioKind, isChainable } from './wdioKind.js' import { isJasmineStringAsymmetricMatcher, toArray } from '../utils.js' import { isBrowser, isMultiRemoteBrowser } from './multiRemoteUtils.js' @@ -17,6 +18,11 @@ export const getSelector = (el: WebdriverIO.Element | WebdriverIO.ElementArray | return result } +const isAwaitedElementOrList = (value: unknown): value is WebdriverIO.Element | WebdriverIO.ElementArray => { + const kind = getWdioKind(value) + return (kind === 'element' && !isChainable(value)) || kind === 'element-array' +} + export const getSelectors = (el: WebdriverIO.Element | WdioElements | WdioMultiRemoteElements): string => { if (!el || typeof el !== 'object') { return '' @@ -46,7 +52,8 @@ export const getSelectors = (el: WebdriverIO.Element | WdioElements | WdioMultiR parent = el } - while (!!parent && typeof parent === 'object' && 'selector' in parent) { + // Up to the browser or the browsing context. A parent that is a not-awaited `$()` has no selector to show yet. + while (isAwaitedElementOrList(parent)) { const selector = getSelector(parent) const index = isDefined(parent.index) ? `[${parent.index}]` : '' selectors.push(`${isDefined(parent.index) ? '$' : ''}$(\`${selector}\`)${index}`) diff --git a/test/util/elementsUtil.test.ts b/test/util/elementsUtil.test.ts index d94c7cb01..b8ee534f8 100644 --- a/test/util/elementsUtil.test.ts +++ b/test/util/elementsUtil.test.ts @@ -1,8 +1,8 @@ import { vi, test, describe, expect, beforeEach } from 'vitest' import { $, $$ } from '@wdio/globals' -import { awaitElementOrArray, isArray, isArrayOfElement, isElement, isElementArray, isElementArrayLike, isElementOrArrayLike, isMultiRemoteElement, isMultiRemoteElementArray, isElementOrArrayOrMultiRemoteElementLike, isMultiRemoteElementLike, isStrictlyElementArray, wrapExpectedWithArray } from '../../src/util/elementsUtil.js' -import { elementFactory, elementArrayFactory, chainableElementArrayFactory, notFoundElementFactory, elementWithoutSelectorFactory, browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, multiRemoteBrowserFactory } from '../__mocks__/@wdio/globals.js' +import { awaitElementOrArray, isArray, isArrayOfElement, isElement, isElementArrayLike, isElementOrArrayLike, isMultiRemoteElement, isMultiRemoteElementArray, isElementOrArrayOrMultiRemoteElementLike, isMultiRemoteElementLike, isStrictlyElementArray, wrapExpectedWithArray } from '../../src/util/elementsUtil.js' +import { elementFactory, elementArrayFactory, chainableElementArrayFactory, notFoundElementFactory, elementWithoutSelectorFactory, browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, multiRemoteBrowserFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') @@ -445,7 +445,6 @@ describe('elementsUtil', () => { expect(isElementArrayLike(plainMultiRemoteElements())).toBe(false) const elementArray = multiRemoteElementArray() - expect(isElementArray(elementArray)).toBe(false) expect(isStrictlyElementArray(elementArray)).toBe(false) expect(isElementArrayLike(elementArray)).toBe(false) }) @@ -470,4 +469,46 @@ describe('elementsUtil', () => { expect(isElementArrayLike(elements)).toBe(false) }) }) + + describe('the WebdriverIO v10 wdio.kind brand', () => { + const browser = browserFactory() + + test('is not an element without the brand', () => { + expect(isElement({ selector: 'a', parent: browser, getElement: () => {} })).toBe(false) + }) + + test('is not an element list without the brand', () => { + expect(isStrictlyElementArray(Object.assign([], { selector: 'a', parent: browser, foundWith: '$$', getElements: () => {} }))).toBe(false) + }) + + test('is not a multi-remote element without the brand', () => { + expect(isMultiRemoteElement({ isMultiRemote: true, selector: 'a', getElement: () => {} })).toBe(false) + }) + + test('is not a multi-remote element list without the brand', () => { + expect(isMultiRemoteElementArray(Object.assign([], { isMultiRemote: true, selector: 'a', parent: multiRemoteBrowserFactory(), foundWith: '$$' }))).toBe(false) + }) + + test('a browsing context is not an element', () => { + expect(isElement(setWdioKind({ parent: browser, getElement: () => {} }, 'browsing-context'))).toBe(false) + }) + + test('a not-awaited $() is not an element', () => { + expect(isElement($('a'))).toBe(false) + }) + + test('an unbranded copy of an element list is an Element[]', () => { + const copy = [...elementArrayFactory('a', 2)] + + expect(isArrayOfElement(copy)).toBe(true) + expect(isStrictlyElementArray(copy)).toBe(false) + }) + + test('a multi-remote element without parent is a multi-remote element', () => { + const element = createMultiRemoteElementMock({ chrome: browserFactory(), firefox: browserFactory() }, 'a') + + expect('parent' in element).toBe(false) + expect(isMultiRemoteElement(element)).toBe(true) + }) + }) }) diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index 37f77a79e..2e770bc24 100644 --- a/test/util/formatMessage.test.ts +++ b/test/util/formatMessage.test.ts @@ -826,3 +826,13 @@ Expect multi-remote.$$(\`button\`) to be displayed }) }) }) + +describe('the subject of an unbranded copy of an element list', () => { + test('lists each selector of the copy', () => { + const copy = [...elementArrayFactory('a', 2)] + + const message = stripAnsi(enhanceError(copy, 'b', 'c', { isNot: false }, 'have', 'text')) + + expect(message).toContain('Expect [$$(`a`)[0],$$(`a`)[1]] to have text') + }) +}) From 3c8125b68519a53f0f1a250c834e6e54610a3b3f Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 21:48:18 -0400 Subject: [PATCH 04/11] refactor!: identify browsers and mocks by the wdio.kind brand `isBrowser()` read the constructor name (`Browser`, `MultiRemoteDriver`), an internal name of `@wdio/utils`. It now reads the WebdriverIO v10 `wdio.kind` brand: `browser`, or `browsing-context`, which also has the `Browser` constructor name. So the failure message of a browsing context does not change (TODO(#2298)). `isMultiRemoteBrowser()` also checks the `browser` brand: a multi-remote element has the multi-remote flag too. `isMock()` and `isMultiRemoteMock()` read the `mock` brand, not the `calls` array or the `instances` and `getInstance` of the object. BREAKING CHANGE: an object without the WebdriverIO v10 `wdio.kind` brand is not a browser or a mock. An array of such fake mocks is not checked per instance anymore. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- src/util/multiRemoteUtils.ts | 20 ++++++------ test/matchers/mock/toBeRequested.test.ts | 9 ++++++ test/util/formatMessage.test.ts | 14 +++++++- test/util/multiRemoteUtils.test.ts | 41 ++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/util/multiRemoteUtils.ts b/src/util/multiRemoteUtils.ts index 178649a89..312651ef2 100644 --- a/src/util/multiRemoteUtils.ts +++ b/src/util/multiRemoteUtils.ts @@ -1,5 +1,6 @@ import { isAsymmetricMatcher } from '../utils.js' import type { WdioMultiRemoteElementArray, WdioMultiRemoteMock } from '../types.js' +import { getWdioKind } from './wdioKind.js' export const isMultiRemoteValues = (value: unknown, existingInstanceNames?: string[]): value is MultiRemoteValues => { if (value && typeof value === 'object' && !Array.isArray(value) && !isAsymmetricMatcher(value) && !(value instanceof RegExp) && Object.keys(value).length > 0) { @@ -81,9 +82,9 @@ export const getGlobalMultiRemoteInstanceNames = (): string[] | undefined => { } } -/** A `WebdriverIO.Mock`, recognized by its log of `calls` */ +/** A `WebdriverIO.Mock` of one browser */ export const isMock = (obj: unknown): obj is WebdriverIO.Mock => { - return typeof obj === 'object' && obj !== null && Array.isArray((obj as { calls?: unknown }).calls) + return getWdioKind(obj) === 'mock' && !hasMultiRemoteFlag(obj) } /** The mocks returned by a multi-remote `mock()`, one per instance, or any other non-empty array of mocks */ @@ -93,9 +94,7 @@ export const isMockArray = (obj: unknown): obj is WebdriverIO.Mock[] => { /** A WebdriverIO v10 multi-remote `mock()`: a `MultiRemoteMock`, which is not an array and has no `calls` */ export const isMultiRemoteMock = (obj: unknown): obj is WdioMultiRemoteMock => { - return typeof obj === 'object' && obj !== null && !Array.isArray(obj) && hasMultiRemoteFlag(obj) - && Array.isArray((obj as { instances?: unknown }).instances) - && typeof (obj as { getInstance?: unknown }).getInstance === 'function' + return getWdioKind(obj) === 'mock' && hasMultiRemoteFlag(obj) } /** The instance names of the mocks taken from a `MultiRemoteMock`, which knows them, also after `select()` */ @@ -148,10 +147,13 @@ export const hasMultiRemoteFlag = (obj: unknown): boolean => (obj as { isMultiRemote?: unknown } | null | undefined)?.isMultiRemote === true export const isMultiRemoteBrowser = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => - hasMultiRemoteFlag(browser) + getWdioKind(browser) === 'browser' && hasMultiRemoteFlag(browser) +/** + * A browser, multi-remote or not, or a browsing context (a tab, a window or a frame). + * TODO(#2298) decide if a browsing context is a browser subject + */ export const isBrowser = (obj: unknown): obj is WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser => { - // The `@wdio/globals` proxies bind every function they return, `constructor` included, so its name is prefixed with `bound ` - const name = (obj as { constructor?: { name?: string } } | undefined)?.constructor?.name?.replace(/^bound /, '') - return name === 'Browser' || !!name?.endsWith('MultiRemoteDriver') + const kind = getWdioKind(obj) + return kind === 'browser' || kind === 'browsing-context' } diff --git a/test/matchers/mock/toBeRequested.test.ts b/test/matchers/mock/toBeRequested.test.ts index 64c8367ea..6b7a2e84b 100644 --- a/test/matchers/mock/toBeRequested.test.ts +++ b/test/matchers/mock/toBeRequested.test.ts @@ -110,6 +110,15 @@ Received : 1` }) }) +describe('toBeRequested on a fake mock without the wdio.kind brand', () => { + test('fails with its normal message and does not throw', async () => { + const result = await toBeRequested({ calls: [] } as unknown as Mock, { wait: 0 }) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toContain('Expect mock to be called') + }) +}) + describe('toBeRequested on multi-remote mocks', () => { const thisContext = { toBeRequested } const thisNotContext = { isNot: true, toBeRequested } diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index 2e770bc24..b53f4f907 100644 --- a/test/util/formatMessage.test.ts +++ b/test/util/formatMessage.test.ts @@ -2,7 +2,7 @@ import { test, describe, beforeEach, expect, vi } from 'vitest' import { INVERTED_COLOR, printDiffOrStringify } from 'jest-matcher-utils' import { enhanceError, enhanceErrorBe } from '../../src/util/formatMessage.js' import stripAnsi from 'strip-ansi' -import { browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, elementArrayFactory, elementFactory } from '../__mocks__/@wdio/globals.js' +import { browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, elementArrayFactory, elementFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' import { jasmine } from '../__mocks__/jasmine.js' const multiRemoteBrowsers = () => ({ chrome: browserFactory(), firefox: browserFactory() }) @@ -836,3 +836,15 @@ describe('the subject of an unbranded copy of an element list', () => { expect(message).toContain('Expect [$$(`a`)[0],$$(`a`)[1]] to have text') }) }) + +describe('the subject of a browsing context', () => { + test.each([undefined, 'window' as const])('names a browsing context like its browser (browserTargetType: %s)', (browserTargetType) => { + const message = (kind: 'browser' | 'browsing-context') => stripAnsi(enhanceError( + setWdioKind({ requestedCapabilities: { browserName: 'chrome' } }, kind), + 'a', 'b', { isNot: false, browserTargetType }, 'have', 'title' + )) + + expect(message('browsing-context')).toEqual(message('browser')) + expect(message('browser')).toContain(browserTargetType === 'window' ? "Expect chrome's window to have title" : 'Expect chrome to have title') + }) +}) diff --git a/test/util/multiRemoteUtils.test.ts b/test/util/multiRemoteUtils.test.ts index 0187bb9f4..da8f03bce 100644 --- a/test/util/multiRemoteUtils.test.ts +++ b/test/util/multiRemoteUtils.test.ts @@ -1,8 +1,8 @@ import { vi, test, describe, expect, afterEach } from 'vitest' -import { getElementsPerInstance, getGlobalMultiRemoteInstanceNames, getPerInstanceValues, getMockInstanceNames, hasMultiRemoteFlag, hasSameInstanceNames, isBrowser, isMockArray, isMultiRemoteMatcher, isMultiRemoteValues } from '../../src/util/multiRemoteUtils.js' +import { getElementsPerInstance, getGlobalMultiRemoteInstanceNames, getPerInstanceValues, getMockInstanceNames, hasMultiRemoteFlag, hasSameInstanceNames, isBrowser, isMock, isMockArray, isMultiRemoteBrowser, isMultiRemoteMatcher, isMultiRemoteMock, isMultiRemoteValues } from '../../src/util/multiRemoteUtils.js' import { multiRemote } from '../../src/api/index.js' -import { browserFactory, createMultiRemoteElementArrayMock, multiRemoteBrowserFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' +import { browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, multiRemoteBrowserFactory, multiRemoteMockFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') @@ -28,6 +28,43 @@ describe('multiRemoteUtils', () => { test.each([undefined, null, {}, 'browser', Object.create(null)])('does not recognize %s', (value) => { expect(isBrowser(value)).toBe(false) }) + + test('is not a browser by its constructor name', () => { + expect(isBrowser(new (class Browser {})())).toBe(false) + }) + + test('a browsing context is a browser subject, until #2298 decides', () => { + expect(isBrowser(setWdioKind({}, 'browsing-context'))).toBe(true) + }) + }) + + describe(isMultiRemoteBrowser, () => { + test('a multi-remote element is not a multi-remote browser', () => { + const element = createMultiRemoteElementMock({ chrome: browserFactory(), firefox: browserFactory() }, 'sel') + + expect(isMultiRemoteBrowser(element as unknown as WebdriverIO.MultiRemoteBrowser)).toBe(false) + }) + + test('is a multi-remote browser', () => { + expect(isMultiRemoteBrowser(multiRemoteBrowserFactory())).toBe(true) + }) + }) + + describe(isMock, () => { + test('is not a mock without the brand', () => { + expect(isMock({ calls: [] })).toBe(false) + }) + + test('a multi-remote mock is not a mock', () => { + const multiRemoteMock = multiRemoteMockFactory({ chrome: setWdioKind({ calls: [] }, 'mock') as unknown as WebdriverIO.Mock }) + + expect(isMock(multiRemoteMock)).toBe(false) + expect(isMultiRemoteMock(multiRemoteMock)).toBe(true) + }) + + test('is not a multi-remote mock without the brand', () => { + expect(isMultiRemoteMock({ isMultiRemote: true, instances: ['chrome'], getInstance: () => ({ calls: [] }) })).toBe(false) + }) }) describe(getPerInstanceValues, () => { From 4bf11398aa456aff8ad309ff33149bb17d4076c9 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 21:51:36 -0400 Subject: [PATCH 05/11] docs: explain the wdio.kind brand in the v8 migration guide The matchers identify WebdriverIO objects by the WebdriverIO v10 `wdio.kind` brand, so a hand-made fake needs it. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- docs/Migrations.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/Migrations.md b/docs/Migrations.md index e5376b41a..d17673814 100644 --- a/docs/Migrations.md +++ b/docs/Migrations.md @@ -60,6 +60,18 @@ Pass the `$$()`, `custom$$()` or `react$$()` result as is. A plain `MultiRemoteE Retries still re-fetch `$$()` elements from their scope. Only the best-effort re-fetch of a plain array from the global `multiRemoteBrowser`, and its warning, are removed. +## WebdriverIO objects are identified by their brand + +The matchers find a browser, an element, an element list or a mock by the WebdriverIO v10 brand `Symbol.for('wdio.kind')`, not by its properties or its class name. The objects that WebdriverIO gives have the brand, so tests that pass them need no change. + +A hand-made fake without the brand, for example in your own unit tests, is not recognized: the matcher fails with its normal message, or throws for an array of fake mocks. Give the fake the brand of the object it replaces, `'browser'`, `'browsing-context'`, `'element'`, `'element-array'` or `'mock'`: + +```ts +const element = Object.defineProperty({ selector: 'h1', getText: async () => 'Welcome' }, Symbol.for('wdio.kind'), { value: 'element' }) +``` + +A copy of an element list, such as `[...elements]`, is still an array of elements, because each element keeps its brand. + ## Removed deprecated APIs v8.0.0 removes the APIs deprecated in v5.6.9 to v6.0.0, listed in [v5 to v6](#migration-guide-v5-to-v6) below. From 03856685ace29e0108d17807ff23b4019871fc04 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 22:01:29 -0400 Subject: [PATCH 06/11] fix: await a not-awaited multi-remote $$() before reading its elements In WebdriverIO v10, a not-awaited `$$()` is the element list itself, not a Promise, and its `length` is a Promise until the list is loaded. A not-awaited multi-remote `$$()` was used as is, so it counted 0 elements: `toBeElementsArrayOfSize(0)` passed on a page with elements, and the element matchers failed with `wait: 0`. `awaitElementOrArray()` and `awaitElementArray()` now also await an element list that still has `then` (`isNotAwaitedElementList()`). Awaiting it gives the same list, so a refetch still updates the user's list in place. The list mocks load later, like the `load()` of WebdriverIO v10, and the multi-remote `$$()` mock is a not-awaited list, not a Promise. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- .../test/specs/wdio-matchers.test.ts | 6 +++ src/util/elementsUtil.ts | 12 ++--- src/util/wdioKind.ts | 7 +++ test/__mocks__/@wdio/globals.ts | 51 +++++++++++++++++-- test/matchers/element/toBeDisplayed.test.ts | 8 ++- .../elements/toBeElementsArrayOfSize.test.ts | 14 ++++- test/util/wdioKind.test.ts | 22 +++++++- 7 files changed, 107 insertions(+), 13 deletions(-) diff --git a/playgrounds/multi-remote-mocha/test/specs/wdio-matchers.test.ts b/playgrounds/multi-remote-mocha/test/specs/wdio-matchers.test.ts index 7c1409030..6c76c7ac7 100644 --- a/playgrounds/multi-remote-mocha/test/specs/wdio-matchers.test.ts +++ b/playgrounds/multi-remote-mocha/test/specs/wdio-matchers.test.ts @@ -409,6 +409,12 @@ describe('WebdriverIO Custom Matchers', () => { await expect(h1).toBeElementsArrayOfSize(expect.multiRemote({ chrome: 2, firefox: { gte: 1 } })) }) + it('should count the elements of a not-awaited $$() at once, without a retry', async () => { + await expect(multiRemoteBrowser.$$('h1')).toBeElementsArrayOfSize(2, { wait: 0 }) + await expect(expect(multiRemoteBrowser.$$('h1')).toBeElementsArrayOfSize(0, { wait: 0 })).rejects.toThrow(/to be elements array of size/) + await expect(multiRemoteBrowser.$$('h1')).toBeDisplayed({ wait: 0 }) + }) + it('should count the elements of each browser when browsers find a different number of elements', async () => { await multiRemoteBrowser.getInstance('firefox')!.url('about:blank') const h1 = multiRemoteBrowser.$$('h1') diff --git a/src/util/elementsUtil.ts b/src/util/elementsUtil.ts index 5cccdd2b5..6ee893137 100644 --- a/src/util/elementsUtil.ts +++ b/src/util/elementsUtil.ts @@ -1,6 +1,6 @@ import { isArrayContainingMatcher } from '../utils.js' import { hasMultiRemoteFlag } from './multiRemoteUtils.js' -import { getWdioKind, isChainable } from './wdioKind.js' +import { getWdioKind, isChainable, isNotAwaitedElementList } from './wdioKind.js' import type { MaybeSomeWdioElementOrArrayMaybePromiseOrMultiRemoteElements, WdioElements, WdioElementsMaybePromise, WdioMultiRemoteElementArray, WdioMultiRemoteElements } from '../types.js' /** @@ -116,9 +116,9 @@ export const awaitElementOrArray = async( let awaitedElements = received - // For non-awaited `$()` or `$$()`, so ChainablePromiseElement | ChainablePromiseArray. - // Extend also to other valid non-awaited case like `$().getElement()`, `$$().getElements()` or `$$().filter()`. - if (awaitedElements instanceof Promise) { + // A not-awaited `$()`, and the Promises of `$().getElement()`, `$$().getElements()` or `$$().filter()`. + // A not-awaited `$$()`, multi-remote or not, is a list that is not loaded yet: its `length` is a Promise until then. + if (awaitedElements instanceof Promise || isNotAwaitedElementList(awaitedElements)) { awaitedElements = await awaitedElements } @@ -149,9 +149,9 @@ export const awaitElementOrArray = async( export const awaitElementArray = async(received: WdioElementsMaybePromise | undefined): Promise<{ elements?: WdioElements, other?: unknown }> => { let awaitedElements = received - // For non-awaited `$$()`, so ChainablePromiseElement | ChainablePromiseArray. + // A not-awaited `$$()`, multi-remote or not, is a list that is not loaded yet: its `length` is a Promise until then. // At some extend it also process non-awaited `$$().getElements()` or `$$().filter()` (e.g. Promise), but typings does not allow it - if (awaitedElements instanceof Promise) { + if (awaitedElements instanceof Promise || isNotAwaitedElementList(awaitedElements)) { awaitedElements = await awaitedElements } diff --git a/src/util/wdioKind.ts b/src/util/wdioKind.ts index e9ca41d7c..fcfb9d283 100644 --- a/src/util/wdioKind.ts +++ b/src/util/wdioKind.ts @@ -21,3 +21,10 @@ export const getWdioKind = (value: unknown): WdioKind | undefined => { /** A not-awaited `$()`: the brand says `element`, but it is a Promise of the element */ export const isChainable = (value: unknown): boolean => (value as { [WDIO_CHAINABLE]?: unknown } | null | undefined)?.[WDIO_CHAINABLE] === true + +/** + * A not-awaited `$$()`: in WebdriverIO v10 it is the element list itself, not a Promise. Until it is loaded, it has + * `then` and its `length` is a Promise, so await it before reading its elements. Awaiting it gives the same list. + */ +export const isNotAwaitedElementList = (value: unknown): boolean => + getWdioKind(value) === 'element-array' && typeof (value as { then?: unknown }).then === 'function' diff --git a/test/__mocks__/@wdio/globals.ts b/test/__mocks__/@wdio/globals.ts index 6aa3123d0..13c28827f 100644 --- a/test/__mocks__/@wdio/globals.ts +++ b/test/__mocks__/@wdio/globals.ts @@ -210,10 +210,12 @@ export function elementArrayFactory(selector: string, length: number = 2, parent export function chainableElementArrayFactory(selector: string, length: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browserFactory()): ChainablePromiseArray { const elementArray = elementArrayFactory(selector, length, parent) let resolved = false - const settle = () => { + let loading: Promise | undefined + // Like the `load()` of WebdriverIO v10: the list is resolved later, not when the load starts + const settle = () => loading ??= new Promise((resolve) => setTimeout(() => { resolved = true - return Promise.resolve(runtimeChainablePromiseArray as unknown as WebdriverIO.ElementArray) - } + resolve(runtimeChainablePromiseArray as unknown as WebdriverIO.ElementArray) + })) const runtimeChainablePromiseArray: ChainablePromiseArray = new Proxy(elementArray, { get(target, prop, receiver) { @@ -352,7 +354,7 @@ export class CustomMultiRemoteDriver { }) vi.mocked(this.$$).mockImplementation((selector: string) => { - return Promise.resolve(createMultiRemoteElementArrayMock(browsers, selector, 2, this as unknown as WebdriverIO.MultiRemoteBrowser)) + return notAwaitedMultiRemoteElementArrayMock(browsers, selector, 2, this as unknown as WebdriverIO.MultiRemoteBrowser) }) vi.mocked(this.setPermissions).mockImplementation((descriptor: object, state: string, oneRealm?: boolean) => { @@ -463,6 +465,47 @@ export function createMultiRemoteElementMock( * ElementArray-like properties (`.parent`, `.foundWith`, `.getElements()`, an async-aware `.forEach()`) and * `isMultiRemote: true`, matching `enhanceElementsArray()` at runtime. */ +/** + * Mocks a not-awaited multi-remote `$$()` like WebdriverIO v10: the `MultiRemoteElementArray` itself, not a Promise. + * Until it is awaited, it has `then`, `catch` and `finally`, and its `length` is a Promise. Awaiting it gives the same list. + */ +export function notAwaitedMultiRemoteElementArrayMock( + browsers: Record, + selector: string, + length = 2, + parent: WebdriverIO.MultiRemoteBrowser | WebdriverIO.MultiRemoteElement = multiRemoteBrowserFactory(browsers) +): WdioMultiRemoteElementArray { + const elementArray = createMultiRemoteElementArrayMock(browsers, selector, length, parent) + let resolved = false + let loading: Promise | undefined + // Like the `load()` of WebdriverIO v10: the list is resolved later, not when the load starts + const settle = () => loading ??= new Promise((resolve) => setTimeout(() => { + resolved = true + resolve(notAwaited) + })) + const notAwaited: WdioMultiRemoteElementArray = new Proxy(elementArray, { + get(target, prop, receiver) { + if (!resolved) { + if (prop === 'then') { + return (onFulfilled?: (value: unknown) => unknown, onRejected?: (reason: unknown) => unknown) => settle().then(onFulfilled, onRejected) + } + if (prop === 'catch' || prop === 'finally') { + return (handler: () => unknown) => settle()[prop](handler) + } + if (prop === 'length') { + return settle().then(() => target.length) + } + } + return Reflect.get(target, prop, receiver) + } + }) + elementArray.getElements = vi.fn().mockImplementation(async () => { + await settle() + return notAwaited + }) + return notAwaited +} + export function createMultiRemoteElementArrayMock( browsers: Record, selector: string, diff --git a/test/matchers/element/toBeDisplayed.test.ts b/test/matchers/element/toBeDisplayed.test.ts index 643266a0e..75637e386 100644 --- a/test/matchers/element/toBeDisplayed.test.ts +++ b/test/matchers/element/toBeDisplayed.test.ts @@ -4,7 +4,7 @@ import { $, $$ } from '@wdio/globals' import { toBeDisplayed } from '../../../src/matchers/element/toBeDisplayed.js' import { executeCommandBe, waitUntil } from '../../../src/utils.js' import stripAnsi from 'strip-ansi' -import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, notFoundElementFactory } from '../../__mocks__/@wdio/globals.js' +import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, notAwaitedMultiRemoteElementArrayMock, notFoundElementFactory } from '../../__mocks__/@wdio/globals.js' import { mockMultiRemoteInstanceCommand } from '../../__fixtures__/utils.js' import { DEFAULT_OPTIONS } from '../../../src/constants.js' import { setDefaultOptions } from '../../../src/index.js' @@ -706,6 +706,12 @@ Received: "not displayed"`) describe('given multi-remote elements', () => { const browsers = () => ({ chrome: browserFactory(), firefox: browserFactory() }) + test('passes at once for a not-awaited multi-remote $$(), which is a list and not a Promise in WebdriverIO v10', async () => { + const result = await thisContext.toBeDisplayed(notAwaitedMultiRemoteElementArrayMock(browsers(), 'sel', 2), { wait: 0 }) + + expect(result.pass).toBe(true) + }) + test.each([ { name: '$()', subject: () => createMultiRemoteElementMock(browsers(), 'sel'), message: `\ Expect multi-remote.$(\`sel\`) to be displayed diff --git a/test/matchers/elements/toBeElementsArrayOfSize.test.ts b/test/matchers/elements/toBeElementsArrayOfSize.test.ts index c152f899a..199299ccf 100644 --- a/test/matchers/elements/toBeElementsArrayOfSize.test.ts +++ b/test/matchers/elements/toBeElementsArrayOfSize.test.ts @@ -2,7 +2,7 @@ import { vi, test, describe, expect, beforeEach, afterEach } from 'vitest' import { $$ } from '@wdio/globals' import { toBeElementsArrayOfSize } from '../../../src/matchers/elements/toBeElementsArrayOfSize.js' -import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, elementArrayFactory, elementFactory, multiRemoteBrowserFactory } from '../../__mocks__/@wdio/globals.js' +import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, elementArrayFactory, elementFactory, multiRemoteBrowserFactory, notAwaitedMultiRemoteElementArrayMock } from '../../__mocks__/@wdio/globals.js' import { refetchElements } from '../../../src/util/refetchElements.js' import stripAnsi from 'strip-ansi' import { multiRemote } from '../../../src/api/index.js' @@ -384,6 +384,18 @@ Received : 2` expect(result.pass).toBe(true) }) + test('passes for a not-awaited multi-remote $$(), which is a list and not a Promise in WebdriverIO v10', async () => { + const result = await thisContext.toBeElementsArrayOfSize(notAwaitedMultiRemoteElementArrayMock(browsers(), 'sel', 2), 2, { wait: 0 }) + + expect(result.pass).toBe(true) + }) + + test('fails for a not-awaited multi-remote $$() with another size', async () => { + const result = await thisContext.toBeElementsArrayOfSize(notAwaitedMultiRemoteElementArrayMock(browsers(), 'sel', 2), 0, { wait: 0 }) + + expect(result.pass).toBe(false) + }) + test('passes with a promise of multi-remote elements', async () => { const elements = Promise.resolve(createMultiRemoteElementArrayMock(browsers(), 'sel', 2)) diff --git a/test/util/wdioKind.test.ts b/test/util/wdioKind.test.ts index 9f758bbfc..59257fd69 100644 --- a/test/util/wdioKind.test.ts +++ b/test/util/wdioKind.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' -import { getWdioKind, isChainable } from '../../src/util/wdioKind.js' +import { getWdioKind, isChainable, isNotAwaitedElementList } from '../../src/util/wdioKind.js' describe(getWdioKind, () => { test.each(['browser', 'element', 'element-array', 'mock', 'browsing-context'])('returns the %s kind', (kind) => { @@ -46,3 +46,23 @@ describe(isChainable, () => { expect(isChainable(value)).toBe(false) }) }) + +describe(isNotAwaitedElementList, () => { + const list = (props: object) => Object.defineProperty(Object.assign([], props), Symbol.for('wdio.kind'), { value: 'element-array' }) + // A thenable is what these tests check + // eslint-disable-next-line unicorn/no-thenable + const withThen = (value: T): T => Object.assign(value, { then: () => {} }) + + test('is true for a not-awaited `$$()`: an element list that still has `then`', () => { + expect(isNotAwaitedElementList(list(withThen({})))).toBe(true) + }) + + test.each([ + ['an awaited element list', list({})], + ['a thenable without the brand', withThen({})], + ['a not-awaited `$()`', withThen({ [Symbol.for('wdio.kind')]: 'element', [Symbol.for('wdio.chainable')]: true })], + ['undefined', undefined], + ])('is false for %s', (_, value) => { + expect(isNotAwaitedElementList(value)).toBe(false) + }) +}) From 9e1f12b19d94d9c527fb297735087e06ccb69c09 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 22:01:30 -0400 Subject: [PATCH 07/11] docs: give the fake element of the v8 migration guide a getElement() The matchers call `getElement()` on an element, so the example threw a TypeError. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- docs/Migrations.md | 8 ++++++-- test/matchers/element/toHaveText.test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/Migrations.md b/docs/Migrations.md index d17673814..c21f19ff8 100644 --- a/docs/Migrations.md +++ b/docs/Migrations.md @@ -64,10 +64,14 @@ Retries still re-fetch `$$()` elements from their scope. Only the best-effort re The matchers find a browser, an element, an element list or a mock by the WebdriverIO v10 brand `Symbol.for('wdio.kind')`, not by its properties or its class name. The objects that WebdriverIO gives have the brand, so tests that pass them need no change. -A hand-made fake without the brand, for example in your own unit tests, is not recognized: the matcher fails with its normal message, or throws for an array of fake mocks. Give the fake the brand of the object it replaces, `'browser'`, `'browsing-context'`, `'element'`, `'element-array'` or `'mock'`: +A hand-made fake without the brand, for example in your own unit tests, is not recognized: the matcher fails with its normal message, or throws for an array of fake mocks. Give the fake the brand of the object it replaces, `'browser'`, `'browsing-context'`, `'element'`, `'element-array'` or `'mock'`, and the methods that the matcher calls. A fake element also needs `getElement()`: ```ts -const element = Object.defineProperty({ selector: 'h1', getText: async () => 'Welcome' }, Symbol.for('wdio.kind'), { value: 'element' }) +const element = Object.defineProperty({ + selector: 'h1', + getText: async () => 'Welcome', + async getElement() { return this }, +}, Symbol.for('wdio.kind'), { value: 'element' }) ``` A copy of an element list, such as `[...elements]`, is still an array of elements, because each element keeps its brand. diff --git a/test/matchers/element/toHaveText.test.ts b/test/matchers/element/toHaveText.test.ts index eca51810a..9d08951cf 100755 --- a/test/matchers/element/toHaveText.test.ts +++ b/test/matchers/element/toHaveText.test.ts @@ -1518,3 +1518,17 @@ Expect multi-remote.$(\`sel\`) to have text }) }) }) + +describe('a hand-made fake element with the wdio.kind brand, as in the v8 migration guide', () => { + test('passes toHaveText', async () => { + const element = Object.defineProperty({ + selector: 'h1', + getText: async () => 'Welcome', + async getElement() { return this }, + }, Symbol.for('wdio.kind'), { value: 'element' }) + + const result = await toHaveText.call({ isNot: false }, element as unknown as WebdriverIO.Element, 'Welcome', { wait: 0 }) + + expect(result.pass).toBe(true) + }) +}) From 0b3bc3e700145c65197f262022c3c5f086507212 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Oct 2026 22:28:22 -0400 Subject: [PATCH 08/11] test: mock the not-awaited multi-remote $() and element $$() like WebdriverIO 10.0.0 `multiRemoteBrowser.$()` and the `$()` of a multi-remote element are now Promises with the `element` brand and `wdio.chainable`, and the `$$()` of a multi-remote element is a list that is not loaded yet. Before they are loaded, the list mocks now throw on `for...of` and spread, as WebdriverIO 10.0.0 does. New tests cover these not-awaited multi-remote values in `toBeDisplayed` and `toBeElementsArrayOfSize`. Without the await of a not-loaded list, the `$$()` tests fail. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- test/__mocks__/@wdio/globals.ts | 36 ++++++++++++++++--- test/globals_mock.test.ts | 7 ++++ test/matchers/element/toBeDisplayed.test.ts | 13 ++++++- .../elements/toBeElementsArrayOfSize.test.ts | 11 +++++- 4 files changed, 61 insertions(+), 6 deletions(-) diff --git a/test/__mocks__/@wdio/globals.ts b/test/__mocks__/@wdio/globals.ts index 13c28827f..05e0487b6 100644 --- a/test/__mocks__/@wdio/globals.ts +++ b/test/__mocks__/@wdio/globals.ts @@ -152,6 +152,28 @@ export const $Factory = (element: WebdriverIO.Element, findDelay = 0): Chainable return runtimeChainableElement as unknown as ChainablePromiseElement } +/** + * Mocks a not-awaited multi-remote `$()` like WebdriverIO v10: a Promise of the element, with the `element` brand and `wdio.chainable` + */ +const notAwaitedMultiRemoteElementMock = (element: Promise): ChainablePromiseElement => + new Proxy(element, { + get(target, prop) { + if (prop === WDIO_KIND) { + return 'element' + } + if (prop === WDIO_CHAINABLE) { + return true + } + const value = Reflect.get(target, prop) + return typeof value === 'function' ? value.bind(target) : value + } + }) as unknown as ChainablePromiseElement + +/** Like WebdriverIO v10: `for...of` and the spread of a list throw until the list is loaded */ +const notLoadedIterator = (): never => { + throw new Error('Cannot synchronously iterate over an element list that has not resolved yet. Use `for await (const el of $$(\'...\')) { ... }` instead.') +} + /** * Mirrors WebdriverIO v10 `StrictSelectorError`, thrown by a strict `$()` when the selector matches several elements. * @see https://github.com/webdriverio/webdriverio/issues/15666 @@ -229,6 +251,9 @@ export function chainableElementArrayFactory(selector: string, length: number, p if (prop === 'length') { return settle().then(() => target.length) } + if (prop === Symbol.iterator) { + return notLoadedIterator + } } if (typeof prop === 'string' && /^\d+$/.test(prop)) { // Simulate index out of bounds error when asking for an element outside the array length @@ -348,10 +373,10 @@ export class CustomMultiRemoteDriver { * Common browser methods */ // Like `MultiRemote.elementWrapper()` at runtime: one `MultiRemoteElement` wrapping each instance's resolved element - vi.mocked(this.$).mockImplementation(async (selector: string) => { + vi.mocked(this.$).mockImplementation((selector: string) => notAwaitedMultiRemoteElementMock((async () => { const instanceElements = await Promise.all(availableBrowsers.map((browser) => browser.$(selector))) as unknown as WebdriverIO.Element[] return buildMultiRemoteElementWrapper(this.instances, instanceElements, selector) - }) + })())) vi.mocked(this.$$).mockImplementation((selector: string) => { return notAwaitedMultiRemoteElementArrayMock(browsers, selector, 2, this as unknown as WebdriverIO.MultiRemoteBrowser) @@ -414,7 +439,7 @@ const buildMultiRemoteElementWrapper = ( $$: () => instanceElements[index].$$(subSelector), } satisfies Partial as unknown as WebdriverIO.Browser }) - return createMultiRemoteElementMock(childBrowsers, subSelector) + return notAwaitedMultiRemoteElementMock(Promise.resolve(createMultiRemoteElementMock(childBrowsers, subSelector))) }), // Delegate $$() across all browser instances @@ -426,7 +451,7 @@ const buildMultiRemoteElementWrapper = ( $$: () => instanceElements[index].$$(subSelector), } satisfies Partial as unknown as WebdriverIO.Browser }) - return createMultiRemoteElementArrayMock(childBrowsers, subSelector, 2, multiRemoteElement) + return notAwaitedMultiRemoteElementArrayMock(childBrowsers, subSelector, 2, multiRemoteElement) }), // Common element method proxies returning Promise.all array of results @@ -495,6 +520,9 @@ export function notAwaitedMultiRemoteElementArrayMock( if (prop === 'length') { return settle().then(() => target.length) } + if (prop === Symbol.iterator) { + return notLoadedIterator + } } return Reflect.get(target, prop, receiver) } diff --git a/test/globals_mock.test.ts b/test/globals_mock.test.ts index 53bf0824d..d942d8028 100644 --- a/test/globals_mock.test.ts +++ b/test/globals_mock.test.ts @@ -84,6 +84,13 @@ describe('globals mock', () => { expect(await els.getElements()).toEqual(els) }) + it('should throw on a synchronous iteration until it is awaited, like WebdriverIO v10', async () => { + const els = $$('foo') + expect(() => [...els]).toThrow('Cannot synchronously iterate over an element list that has not resolved yet') + + expect([...await els]).toHaveLength(2) + }) + it('should allow calling getElements on the chainable promise', async () => { const chainable = $$('foo') // Like WebdriverIO v10, which keeps `'getElements' in` true on a not-awaited `$$()` diff --git a/test/matchers/element/toBeDisplayed.test.ts b/test/matchers/element/toBeDisplayed.test.ts index 75637e386..9d2aacf25 100644 --- a/test/matchers/element/toBeDisplayed.test.ts +++ b/test/matchers/element/toBeDisplayed.test.ts @@ -4,7 +4,7 @@ import { $, $$ } from '@wdio/globals' import { toBeDisplayed } from '../../../src/matchers/element/toBeDisplayed.js' import { executeCommandBe, waitUntil } from '../../../src/utils.js' import stripAnsi from 'strip-ansi' -import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, notAwaitedMultiRemoteElementArrayMock, notFoundElementFactory } from '../../__mocks__/@wdio/globals.js' +import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, multiRemoteBrowserFactory, notAwaitedMultiRemoteElementArrayMock, notFoundElementFactory } from '../../__mocks__/@wdio/globals.js' import { mockMultiRemoteInstanceCommand } from '../../__fixtures__/utils.js' import { DEFAULT_OPTIONS } from '../../../src/constants.js' import { setDefaultOptions } from '../../../src/index.js' @@ -712,6 +712,17 @@ Received: "not displayed"`) expect(result.pass).toBe(true) }) + test.each([ + { name: 'multiRemoteBrowser.$()', subject: () => multiRemoteBrowserFactory(browsers()).$('sel') }, + { name: 'multi-remote element .$()', subject: () => createMultiRemoteElementMock(browsers(), 'parent').$('sel') }, + { name: 'multi-remote element .$$()', subject: () => createMultiRemoteElementMock(browsers(), 'parent').$$('sel') }, + ])('passes at once for a not-awaited $name', async ({ subject }) => { + // TODO(#2255) use the WebdriverIO v10 `MultiRemoteElementArray` type in the matcher types + const result = await thisContext.toBeDisplayed(subject() as unknown as WebdriverIO.MultiRemoteElement, { wait: 0 }) + + expect(result.pass).toBe(true) + }) + test.each([ { name: '$()', subject: () => createMultiRemoteElementMock(browsers(), 'sel'), message: `\ Expect multi-remote.$(\`sel\`) to be displayed diff --git a/test/matchers/elements/toBeElementsArrayOfSize.test.ts b/test/matchers/elements/toBeElementsArrayOfSize.test.ts index 199299ccf..e0a32b2aa 100644 --- a/test/matchers/elements/toBeElementsArrayOfSize.test.ts +++ b/test/matchers/elements/toBeElementsArrayOfSize.test.ts @@ -2,7 +2,7 @@ import { vi, test, describe, expect, beforeEach, afterEach } from 'vitest' import { $$ } from '@wdio/globals' import { toBeElementsArrayOfSize } from '../../../src/matchers/elements/toBeElementsArrayOfSize.js' -import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, elementArrayFactory, elementFactory, multiRemoteBrowserFactory, notAwaitedMultiRemoteElementArrayMock } from '../../__mocks__/@wdio/globals.js' +import { browserFactory, chainableElementArrayFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, elementArrayFactory, elementFactory, multiRemoteBrowserFactory, notAwaitedMultiRemoteElementArrayMock } from '../../__mocks__/@wdio/globals.js' import { refetchElements } from '../../../src/util/refetchElements.js' import stripAnsi from 'strip-ansi' import { multiRemote } from '../../../src/api/index.js' @@ -390,6 +390,15 @@ Received : 2` expect(result.pass).toBe(true) }) + test('counts a not-awaited $$() of a multi-remote element', async () => { + const element = createMultiRemoteElementMock(browsers(), 'parent') + // TODO(#2255) use the WebdriverIO v10 `MultiRemoteElementArray` type in the matcher types + const notAwaited = () => element.$$('sel') as unknown as WdioMultiRemoteElementArray + + expect((await thisContext.toBeElementsArrayOfSize(notAwaited(), 2, { wait: 0 })).pass).toBe(true) + expect((await thisContext.toBeElementsArrayOfSize(notAwaited(), 0, { wait: 0 })).pass).toBe(false) + }) + test('fails for a not-awaited multi-remote $$() with another size', async () => { const result = await thisContext.toBeElementsArrayOfSize(notAwaitedMultiRemoteElementArrayMock(browsers(), 'sel', 2), 0, { wait: 0 }) From 0429d832eb4679ec696fd7a76df0fdfdfc2421b7 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Tue, 6 Oct 2026 06:17:41 -0400 Subject: [PATCH 09/11] test: guard why an element list must be an array, and explain a loaded element In WebdriverIO v10, a chained `$('a').$$('b')` or a custom `$$` command before `await` is a Promise proxy with the `element-array` brand. Only `Array.isArray()` keeps `isStrictlyElementArray()` from accepting it, and no test failed when it was removed: a new test now does. The comment of `isElement()` now says that an item of an awaited `$$()` and the result of `getElement()` are elements too. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- src/util/elementsUtil.ts | 4 +++- test/util/elementsUtil.test.ts | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/util/elementsUtil.ts b/src/util/elementsUtil.ts index 6ee893137..3ea25547b 100644 --- a/src/util/elementsUtil.ts +++ b/src/util/elementsUtil.ts @@ -39,12 +39,14 @@ export const isArray = (obj: unknown): obj is unknown[] | WebdriverIO.ElementArr */ export const isStrictlyElementArray = (obj: unknown): obj is WebdriverIO.ElementArray => { return getWdioKind(obj) === 'element-array' + // A chained `$('a').$$('b')` or a custom `$$` command before `await` is a Promise with the same brand && Array.isArray(obj) && !isMultiRemote(obj) } /** - * An awaited `$()`: a not-awaited `$()` has the same brand, but it is a Promise of the element. + * A loaded element: an awaited `$()`, an item of an awaited `$$()`, or the result of `getElement()`. + * A not-awaited `$()` has the same brand, but it is a Promise of the element (`wdio.chainable`). */ export const isElement = (obj: unknown): obj is WebdriverIO.Element => { return getWdioKind(obj) === 'element' diff --git a/test/util/elementsUtil.test.ts b/test/util/elementsUtil.test.ts index b8ee534f8..722613997 100644 --- a/test/util/elementsUtil.test.ts +++ b/test/util/elementsUtil.test.ts @@ -477,6 +477,13 @@ describe('elementsUtil', () => { expect(isElement({ selector: 'a', parent: browser, getElement: () => {} })).toBe(false) }) + test('a chained $().$$() before await is a Promise with the element-array brand, not an element list', () => { + // Like WebdriverIO v10 `chainKind('$$')` on the Promise proxy of a chained query or of a custom `$$` command + const chainedList = setWdioKind(Promise.resolve(elementArrayFactory('b')), 'element-array') + + expect(isStrictlyElementArray(chainedList)).toBe(false) + }) + test('is not an element list without the brand', () => { expect(isStrictlyElementArray(Object.assign([], { selector: 'a', parent: browser, foundWith: '$$', getElements: () => {} }))).toBe(false) }) From 3e43eb95da11d56df840d2afad578c4248b8647f Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Tue, 6 Oct 2026 06:39:19 -0400 Subject: [PATCH 10/11] refactor: always await the received elements `awaitElementOrArray()` and `awaitElementArray()` checked for a Promise or for a list with the `element-array` brand and a `then` before they awaited the received value. A plain `await` is simpler and does the same: it gives back a value that is not a thenable, and it awaits each pending value, a not-awaited `$$()` included. `isNotAwaitedElementList()` is not used anymore, so it is removed. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- src/util/elementsUtil.ts | 23 +++++++++-------------- src/util/wdioKind.ts | 7 ------- test/util/wdioKind.test.ts | 22 +--------------------- 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/src/util/elementsUtil.ts b/src/util/elementsUtil.ts index 3ea25547b..aa0c48491 100644 --- a/src/util/elementsUtil.ts +++ b/src/util/elementsUtil.ts @@ -1,6 +1,6 @@ import { isArrayContainingMatcher } from '../utils.js' import { hasMultiRemoteFlag } from './multiRemoteUtils.js' -import { getWdioKind, isChainable, isNotAwaitedElementList } from './wdioKind.js' +import { getWdioKind, isChainable } from './wdioKind.js' import type { MaybeSomeWdioElementOrArrayMaybePromiseOrMultiRemoteElements, WdioElements, WdioElementsMaybePromise, WdioMultiRemoteElementArray, WdioMultiRemoteElements } from '../types.js' /** @@ -116,13 +116,10 @@ export const awaitElementOrArray = async( return { other: received } } - let awaitedElements = received - - // A not-awaited `$()`, and the Promises of `$().getElement()`, `$$().getElements()` or `$$().filter()`. - // A not-awaited `$$()`, multi-remote or not, is a list that is not loaded yet: its `length` is a Promise until then. - if (awaitedElements instanceof Promise || isNotAwaitedElementList(awaitedElements)) { - awaitedElements = await awaitedElements - } + // Simpler to always `await` than to check for a Promise or a `then`: `await` gives back a value that is not a thenable. + // In WebdriverIO v10, a not-awaited `$()` is a Promise, but a not-awaited `$$()` is a list with `then` and a `length` that is + // a Promise until it is loaded. `$().getElement()`, `$$().getElements()` and `$$().filter()` are Promises too. + const awaitedElements = await received if (!isElementOrArrayOrMultiRemoteElementLike(awaitedElements)) { return { other: awaitedElements } @@ -150,12 +147,10 @@ export const awaitElementOrArray = async( } export const awaitElementArray = async(received: WdioElementsMaybePromise | undefined): Promise<{ elements?: WdioElements, other?: unknown }> => { - let awaitedElements = received - // A not-awaited `$$()`, multi-remote or not, is a list that is not loaded yet: its `length` is a Promise until then. - // At some extend it also process non-awaited `$$().getElements()` or `$$().filter()` (e.g. Promise), but typings does not allow it - if (awaitedElements instanceof Promise || isNotAwaitedElementList(awaitedElements)) { - awaitedElements = await awaitedElements - } + // Simpler to always `await` than to check for a Promise or a `then`: `await` gives back a value that is not a thenable. + // In WebdriverIO v10, a not-awaited `$$()` is a list with `then` and a `length` that is a Promise until it is loaded. + // It also processes a not-awaited `$$().getElements()` or `$$().filter()` (a Promise), but the types do not allow it. + const awaitedElements = await received if (!isElementArrayLike(awaitedElements)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/util/wdioKind.ts b/src/util/wdioKind.ts index fcfb9d283..e9ca41d7c 100644 --- a/src/util/wdioKind.ts +++ b/src/util/wdioKind.ts @@ -21,10 +21,3 @@ export const getWdioKind = (value: unknown): WdioKind | undefined => { /** A not-awaited `$()`: the brand says `element`, but it is a Promise of the element */ export const isChainable = (value: unknown): boolean => (value as { [WDIO_CHAINABLE]?: unknown } | null | undefined)?.[WDIO_CHAINABLE] === true - -/** - * A not-awaited `$$()`: in WebdriverIO v10 it is the element list itself, not a Promise. Until it is loaded, it has - * `then` and its `length` is a Promise, so await it before reading its elements. Awaiting it gives the same list. - */ -export const isNotAwaitedElementList = (value: unknown): boolean => - getWdioKind(value) === 'element-array' && typeof (value as { then?: unknown }).then === 'function' diff --git a/test/util/wdioKind.test.ts b/test/util/wdioKind.test.ts index 59257fd69..9f758bbfc 100644 --- a/test/util/wdioKind.test.ts +++ b/test/util/wdioKind.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest' -import { getWdioKind, isChainable, isNotAwaitedElementList } from '../../src/util/wdioKind.js' +import { getWdioKind, isChainable } from '../../src/util/wdioKind.js' describe(getWdioKind, () => { test.each(['browser', 'element', 'element-array', 'mock', 'browsing-context'])('returns the %s kind', (kind) => { @@ -46,23 +46,3 @@ describe(isChainable, () => { expect(isChainable(value)).toBe(false) }) }) - -describe(isNotAwaitedElementList, () => { - const list = (props: object) => Object.defineProperty(Object.assign([], props), Symbol.for('wdio.kind'), { value: 'element-array' }) - // A thenable is what these tests check - // eslint-disable-next-line unicorn/no-thenable - const withThen = (value: T): T => Object.assign(value, { then: () => {} }) - - test('is true for a not-awaited `$$()`: an element list that still has `then`', () => { - expect(isNotAwaitedElementList(list(withThen({})))).toBe(true) - }) - - test.each([ - ['an awaited element list', list({})], - ['a thenable without the brand', withThen({})], - ['a not-awaited `$()`', withThen({ [Symbol.for('wdio.kind')]: 'element', [Symbol.for('wdio.chainable')]: true })], - ['undefined', undefined], - ])('is false for %s', (_, value) => { - expect(isNotAwaitedElementList(value)).toBe(false) - }) -}) From 77b213c6baab2529d5da406d12426c2ae27a3645 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Tue, 6 Oct 2026 06:49:02 -0400 Subject: [PATCH 11/11] refactor: find a loaded element by its brand and no `then` `isElement()`, `isMultiRemoteElement()` and the parent walk of the failure message excluded a not-awaited element with the `wdio.chainable` flag. In WebdriverIO 10.0.0, each element with that flag is a Promise, and a loaded element has no `then`. So `getLoadedWdioKind()`, the same rule as `getLoadedWdioKind()` of `@wdio/utils`, gives the brand of a value only when it has no `then`. The same rule as the plain `await` now applies everywhere, and `isChainable()` and `WDIO_CHAINABLE` are removed from `src/`. The mocks still set the flag, as WebdriverIO 10.0.0 does. A new test checks that the failure message stops its walk at a parent that is not loaded (a chained `$('form').$('input')` before `await`). It fails if the walk accepts that parent. Refs #2296 Co-Authored-By: Claude Opus 5.5 --- src/util/elementsUtil.ts | 9 ++++----- src/util/formatMessage.ts | 6 +++--- src/util/wdioKind.ts | 12 +++++++----- test/__mocks__/@wdio/globals.ts | 5 ++++- test/util/formatMessage.test.ts | 13 ++++++++++++- test/util/wdioKind.test.ts | 29 +++++++++++++++++++---------- 6 files changed, 49 insertions(+), 25 deletions(-) diff --git a/src/util/elementsUtil.ts b/src/util/elementsUtil.ts index aa0c48491..f90f59398 100644 --- a/src/util/elementsUtil.ts +++ b/src/util/elementsUtil.ts @@ -1,6 +1,6 @@ import { isArrayContainingMatcher } from '../utils.js' import { hasMultiRemoteFlag } from './multiRemoteUtils.js' -import { getWdioKind, isChainable } from './wdioKind.js' +import { getLoadedWdioKind, getWdioKind } from './wdioKind.js' import type { MaybeSomeWdioElementOrArrayMaybePromiseOrMultiRemoteElements, WdioElements, WdioElementsMaybePromise, WdioMultiRemoteElementArray, WdioMultiRemoteElements } from '../types.js' /** @@ -46,11 +46,10 @@ export const isStrictlyElementArray = (obj: unknown): obj is WebdriverIO.Element /** * A loaded element: an awaited `$()`, an item of an awaited `$$()`, or the result of `getElement()`. - * A not-awaited `$()` has the same brand, but it is a Promise of the element (`wdio.chainable`). + * A not-awaited `$()` has the same brand, but it is a Promise of the element. */ export const isElement = (obj: unknown): obj is WebdriverIO.Element => { - return getWdioKind(obj) === 'element' - && !isChainable(obj) + return getLoadedWdioKind(obj) === 'element' && !isMultiRemote(obj) } @@ -174,7 +173,7 @@ const isMultiRemote = (obj: unknown): obj is WebdriverIO.MultiRemoteElement | Wd * An awaited multi-remote `$()`, or an item of a multi-remote `$$()`. It has no `parent`. */ export const isMultiRemoteElement = (obj: unknown): obj is WebdriverIO.MultiRemoteElement => { - return getWdioKind(obj) === 'element' && !isChainable(obj) && isMultiRemote(obj) + return getLoadedWdioKind(obj) === 'element' && isMultiRemote(obj) } /** diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index b2c2c28e9..af54787b8 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -3,7 +3,7 @@ import { equals } from '../jasmineUtils.js' import type { MultiRemoteValuesWithArray, WdioElements, WdioMultiRemoteElements } from '../types.js' import { isArrayOfElement, isElementArrayLike, isElementOrArrayLike, isElementOrArrayOrMultiRemoteElementLike, isMultiRemoteElement, isMultiRemoteElementArray, isMultiRemoteElementLike, isStrictlyElementArray } from './elementsUtil.js' import { toJsonString } from './stringUtil.js' -import { getWdioKind, isChainable } from './wdioKind.js' +import { getLoadedWdioKind } from './wdioKind.js' import { isJasmineStringAsymmetricMatcher, toArray } from '../utils.js' import { isBrowser, isMultiRemoteBrowser } from './multiRemoteUtils.js' @@ -19,8 +19,8 @@ export const getSelector = (el: WebdriverIO.Element | WebdriverIO.ElementArray | } const isAwaitedElementOrList = (value: unknown): value is WebdriverIO.Element | WebdriverIO.ElementArray => { - const kind = getWdioKind(value) - return (kind === 'element' && !isChainable(value)) || kind === 'element-array' + const kind = getLoadedWdioKind(value) + return kind === 'element' || kind === 'element-array' } export const getSelectors = (el: WebdriverIO.Element | WdioElements | WdioMultiRemoteElements): string => { diff --git a/src/util/wdioKind.ts b/src/util/wdioKind.ts index e9ca41d7c..00554cf8a 100644 --- a/src/util/wdioKind.ts +++ b/src/util/wdioKind.ts @@ -1,6 +1,5 @@ -// `Symbol.for()` gives the symbols of `@wdio/utils` without a dependency on it, also with two copies of `webdriverio` +// `Symbol.for()` gives the symbol of `@wdio/utils` without a dependency on it, also with two copies of `webdriverio` export const WDIO_KIND = Symbol.for('wdio.kind') -export const WDIO_CHAINABLE = Symbol.for('wdio.chainable') export type WdioKind = 'browser' | 'element' | 'element-array' | 'mock' | 'browsing-context' @@ -18,6 +17,9 @@ export const getWdioKind = (value: unknown): WdioKind | undefined => { return WDIO_KINDS.includes(kind) ? kind as WdioKind : undefined } -/** A not-awaited `$()`: the brand says `element`, but it is a Promise of the element */ -export const isChainable = (value: unknown): boolean => - (value as { [WDIO_CHAINABLE]?: unknown } | null | undefined)?.[WDIO_CHAINABLE] === true +/** + * The brand of a loaded value. A not-awaited `$()` (a Promise) and a not-awaited `$$()` (a list with `then`) have the + * brand too, but their `then` shows that they are not loaded yet. + */ +export const getLoadedWdioKind = (value: unknown): WdioKind | undefined => + typeof (value as { then?: unknown } | null | undefined)?.then === 'function' ? undefined : getWdioKind(value) diff --git a/test/__mocks__/@wdio/globals.ts b/test/__mocks__/@wdio/globals.ts index 05e0487b6..58b41474a 100644 --- a/test/__mocks__/@wdio/globals.ts +++ b/test/__mocks__/@wdio/globals.ts @@ -6,7 +6,10 @@ import { vi } from 'vitest' import type { ChainablePromiseArray, ChainablePromiseElement, ParsedCSSValue } from 'webdriverio' import { Size } from '../../../src/matchers/element/toHaveSize' import type { WdioMultiRemoteElementArray, WdioMultiRemoteMock } from '../../../src/types' -import { WDIO_CHAINABLE, WDIO_KIND, type WdioKind } from '../../../src/util/wdioKind' +import { WDIO_KIND, type WdioKind } from '../../../src/util/wdioKind' + +/** WebdriverIO v10 sets it on a not-awaited element (`$()`, `$$()[i]`) */ +const WDIO_CHAINABLE = Symbol.for('wdio.chainable') /** Brands a mock like WebdriverIO v10 `setWdioKind()`: a non-enumerable `Symbol.for('wdio.kind')`, so a copy has no brand */ export const setWdioKind = (target: T, kind: WdioKind): T => diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index b53f4f907..21bf8a0c3 100644 --- a/test/util/formatMessage.test.ts +++ b/test/util/formatMessage.test.ts @@ -2,7 +2,7 @@ import { test, describe, beforeEach, expect, vi } from 'vitest' import { INVERTED_COLOR, printDiffOrStringify } from 'jest-matcher-utils' import { enhanceError, enhanceErrorBe } from '../../src/util/formatMessage.js' import stripAnsi from 'strip-ansi' -import { browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, elementArrayFactory, elementFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' +import { $Factory, browserFactory, createMultiRemoteElementArrayMock, createMultiRemoteElementMock, elementArrayFactory, elementFactory, setWdioKind } from '../__mocks__/@wdio/globals.js' import { jasmine } from '../__mocks__/jasmine.js' const multiRemoteBrowsers = () => ({ chrome: browserFactory(), firefox: browserFactory() }) @@ -848,3 +848,14 @@ describe('the subject of a browsing context', () => { expect(message('browser')).toContain(browserTargetType === 'window' ? "Expect chrome's window to have title" : 'Expect chrome to have title') }) }) + +describe('the subject of an element whose parent is not loaded', () => { + test('stops at a not-awaited parent, as in a chained $(`form`).$(`input`) before await', () => { + const notAwaitedParent = $Factory(elementFactory('form')) as unknown as WebdriverIO.Element + const element = elementFactory('input', undefined, notAwaitedParent) + + const message = stripAnsi(enhanceError(element, 'b', 'c', { isNot: false }, 'have', 'text')) + + expect(message).toContain('Expect $(`input`) to have text') + }) +}) diff --git a/test/util/wdioKind.test.ts b/test/util/wdioKind.test.ts index 9f758bbfc..c090a213e 100644 --- a/test/util/wdioKind.test.ts +++ b/test/util/wdioKind.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' -import { getWdioKind, isChainable } from '../../src/util/wdioKind.js' +import { getLoadedWdioKind, getWdioKind } from '../../src/util/wdioKind.js' +import { $Factory, chainableElementArrayFactory, elementFactory } from '../__mocks__/@wdio/globals.js' + +vi.mock('@wdio/globals') describe(getWdioKind, () => { test.each(['browser', 'element', 'element-array', 'mock', 'browsing-context'])('returns the %s kind', (kind) => { @@ -33,16 +36,22 @@ describe(getWdioKind, () => { }) }) -describe(isChainable, () => { - test('is true for a not-awaited `$()`', () => { - expect(isChainable({ [Symbol.for('wdio.chainable')]: true })).toBe(true) +describe(getLoadedWdioKind, () => { + const withBrand = (value: T, kind: string): T => Object.defineProperty(value, Symbol.for('wdio.kind'), { value: kind }) + + test.each([ + ['an awaited `$()`', withBrand({}, 'element'), 'element'], + ['an awaited `$$()`', withBrand([], 'element-array'), 'element-array'], + ['a browser', withBrand({}, 'browser'), 'browser'], + ])('returns the kind of %s', (_, value, kind) => { + expect(getLoadedWdioKind(value)).toBe(kind) }) test.each([ - ['an object without the flag', {}], - ['null', null], - ['a flag that is not `true`', { [Symbol.for('wdio.chainable')]: 'true' }], - ])('is false for %s', (_, value) => { - expect(isChainable(value)).toBe(false) + ['a not-awaited `$()`, a Promise of the element', $Factory(elementFactory('a'))], + ['a not-awaited `$$()`, a list with `then`', chainableElementArrayFactory('a', 1)], + ['a value without brand', {}], + ])('returns undefined for %s', (_, value) => { + expect(getLoadedWdioKind(value)).toBeUndefined() }) })