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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/Migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ 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'`, and the methods that the matcher calls. A fake element also needs `getElement()`:

```ts
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.

## 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
74 changes: 31 additions & 43 deletions src/util/elementsUtil.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { isArrayContainingMatcher } from '../utils.js'
import { hasMultiRemoteFlag } from './multiRemoteUtils.js'
import { getLoadedWdioKind, getWdioKind } from './wdioKind.js'
import type { MaybeSomeWdioElementOrArrayMaybePromiseOrMultiRemoteElements, WdioElements, WdioElementsMaybePromise, WdioMultiRemoteElementArray, WdioMultiRemoteElements } from '../types.js'

/**
Expand Down Expand Up @@ -33,32 +34,23 @@ 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'
// A chained `$('a').$$('b')` or a custom `$$` command before `await` is a Promise with the same brand
&& Array.isArray(obj)
&& 'getElements' in obj // specific to ElementArray
&& !isMultiRemote(obj) // Ensure multi-remote elements are excluded
&& !isMultiRemote(obj)
}

/**
* 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.
*/
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 getLoadedWdioKind(obj) === 'element'
&& !isMultiRemote(obj)
}

/**
Expand Down Expand Up @@ -123,13 +115,10 @@ export const awaitElementOrArray = async(
return { other: received }
}

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) {
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 }
Expand All @@ -142,12 +131,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 }
}
Expand All @@ -157,20 +146,18 @@ 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.
// At some extend it also process non-awaited `$$().getElements()` or `$$().filter()` (e.g. Promise<WebdriverIO.Element[]>), but typings does not allow it
if (awaitedElements instanceof Promise) {
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
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() }
}

Expand All @@ -182,17 +169,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 getLoadedWdioKind(obj) === 'element' && 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)
}

/**
Expand Down
9 changes: 8 additions & 1 deletion src/util/formatMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getLoadedWdioKind } from './wdioKind.js'
import { isJasmineStringAsymmetricMatcher, toArray } from '../utils.js'
import { isBrowser, isMultiRemoteBrowser } from './multiRemoteUtils.js'

Expand All @@ -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 = getLoadedWdioKind(value)
return kind === 'element' || kind === 'element-array'
}

export const getSelectors = (el: WebdriverIO.Element | WdioElements | WdioMultiRemoteElements): string => {
if (!el || typeof el !== 'object') {
return ''
Expand Down Expand Up @@ -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}`)
Expand Down
20 changes: 11 additions & 9 deletions src/util/multiRemoteUtils.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> => {
if (value && typeof value === 'object' && !Array.isArray(value) && !isAsymmetricMatcher(value) && !(value instanceof RegExp) && Object.keys(value).length > 0) {
Expand Down Expand Up @@ -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 */
Expand All @@ -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()` */
Expand Down Expand Up @@ -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'
}
25 changes: 25 additions & 0 deletions src/util/wdioKind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// `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 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
}

/**
* 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)
Loading
Loading