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
27 changes: 26 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,23 @@ await expect(browser).toHaveLocalStorageItem('userId', /^user_\d+$/)

## Element Matchers

### Matching a subset of element values

Use `expect.arrayContaining()` to match values from an element collection in any order, allowing extra elements. This works with `toHaveText`, `toHaveHTML`, `toHaveAttribute`, `toHaveElementProperty`, `toHaveValue`, `toHaveElementClass`, `toHaveComputedLabel`, `toHaveComputedRole`, `toHaveId`, and `toHaveHref` (including their aliases).

```js
await expect($$('ul > li')).toHaveText(expect.arrayContaining(['Tea', 'Coffee']))
await expect($$('input')).toHaveValue(expect.arrayContaining(['admin']))
await expect($$('a')).toHaveAttribute('href', expect.arrayContaining([expect.stringContaining('/docs')]))
await expect($$('button')).not.toHaveComputedLabel(expect.arrayContaining(['Delete']))
```

Each attempt reads one value per element concurrently and applies the asymmetric matcher once to the complete array. Selector-backed collections are refetched on retries. Nested matchers, `.not`, and `expect.not.arrayContaining()` retain their normal matching rules. An empty collection matches `arrayContaining([])`; static empty arrays cannot be retried into a non-empty result.

String comparison options such as `trim`, `ignoreCase`, and `containing` do not transform the collected values or nested matchers. Getter options still apply, such as `includeSelectorTag` for HTML and `asString` for properties. Class matching collects each element's complete class attribute, not individual class tokens.

This collection comparison does not change boolean assertions, style or size assertions, or `some()`'s per-element matching. A single element's array-valued property can still be matched directly with `toHaveElementProperty`.

### toBeDisplayed

Calls [`isDisplayed`](https://webdriver.io/docs/api/element/isDisplayed/) on given element.
Expand Down Expand Up @@ -616,6 +633,14 @@ You can assert all of them at once using an array:
await expect($$('ul > li')).toHaveText(['Coffee', 'Tea', 'Milk'])
```

Use `expect.arrayContaining()` to check for a subset of texts in any order. Extra elements are allowed. See [Matching a subset of element values](#matching-a-subset-of-element-values) for retry behavior, options, and other supported matchers.

```js
await expect($$('ul > li')).toHaveText(expect.arrayContaining(['Tea', 'Coffee']))
await expect($$('ul > li')).toHaveText(expect.arrayContaining([expect.stringContaining('Coff')]))
await expect($$('ul > li')).not.toHaveText(expect.arrayContaining(['Juice']))
```

**Note:** Since v6.0.0, to enable strict assertion matching, configure the `useToHaveTextStrictMultiElementsCompareStrategy` flag in your command options or globally via `setFeatureFlags`.

### toHaveHTML
Expand Down Expand Up @@ -1200,4 +1225,4 @@ await expect(mock).toBeRequestedWith({
})
```

**Note:** Known limitations still exist with `jasmine.arrayContaining` and `jasmine.objectContaining`.
`jasmine.arrayContaining()` is also supported for [element collection values](#matching-a-subset-of-element-values), including nested Jasmine matchers. Limitations with Jasmine collection and object matchers may still apply in other assertion contexts.
5 changes: 4 additions & 1 deletion src/jasmineUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,10 @@ function asymmetricMatch(a: any, b: any) {

// Jasmine asymmetric matchers (e.g. objectContaining) expect a matchersUtil
// with an `equals` method as the second argument to asymmetricMatch.
const matchersUtil = { equals, contains: equals };
const matchersUtil = {
equals,
contains: (actual: unknown[], expected: unknown) => actual.some(value => equals(value, expected)),
};

if (asymmetricA) {
return a.asymmetricMatch(b, matchersUtil);
Expand Down
1 change: 1 addition & 0 deletions src/matchers/element/toHaveAttribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export async function toHaveAttributeAndValue(received: MaybeSomeWdioElementOrAr
async (iteration) => {
return await executeCommandWithStrategy( {
unresolvedElements: received,
supportsArrayContaining: true,
expectedValues: expectedValue,
singleElementCompare: (element, values: string | RegExp | AsymmetricMatcher<string> | undefined) => {
return conditionAttributeValueMatchWithExpected(element, attribute, values, options)
Expand Down
1 change: 1 addition & 0 deletions src/matchers/element/toHaveComputedLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export async function toHaveComputedLabel(
async (iteration) => {
return await executeCommandWithStrategy( {
unresolvedElements: received,
supportsArrayContaining: true,
expectedValues: expectedValue,
singleElementCompare: (element, expectedValue: MaybeArrayOrOneOf<string | RegExp | AsymmetricMatcher<string>> | undefined) => singleElementCompare(element, expectedValue, options),
context: { isNot, iteration },
Expand Down
1 change: 1 addition & 0 deletions src/matchers/element/toHaveComputedRole.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export async function toHaveComputedRole(
async (iteration) => {
return await executeCommandWithStrategy( {
unresolvedElements: received,
supportsArrayContaining: true,
expectedValues: expectedValue,
singleElementCompare: (element, expectedValue: MaybeArrayOrOneOf<string | RegExp | AsymmetricMatcher<string>> | undefined) => singleElementCompare(element, expectedValue, options),
context: { isNot, iteration },
Expand Down
1 change: 1 addition & 0 deletions src/matchers/element/toHaveElementClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export async function toHaveElementClass(
async (iteration) => {
return await executeCommandWithStrategy( {
unresolvedElements: received,
supportsArrayContaining: true,
expectedValues: expectedValue,
singleElementCompare: (element, expectedValue: MaybeArray<string | RegExp | AsymmetricMatcher<string>> | undefined) => singleElementCompare(element, attribute, expectedValue, options),
context: { isNot, iteration },
Expand Down
4 changes: 3 additions & 1 deletion src/matchers/element/toHaveElementProperty.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AssertionResult } from 'expect-webdriverio'
import { equals } from '../../jasmineUtils.js'
import { DEFAULT_OPTIONS } from '../../constants.js'
import type { WdioElementMaybePromise, MaybeSomeWdioElementOrArrayMaybePromise, WdioElementsMaybePromise } from '../../types.js'
import type { CompareResult } from '../../util/executeCommand.js'
Expand Down Expand Up @@ -26,7 +27,7 @@ async function condition(

if (propertyValue === null || propertyValue === undefined || (!(expectedValue instanceof RegExp) && typeof propertyValue !== 'string' && !asString)) {
if (isAsymmetricMatcher(expectedValue)) {
return { success: expectedValue.asymmetricMatch(propertyValue), actual: propertyValue }
return { success: equals(propertyValue, expectedValue), actual: propertyValue }
}
return { success: propertyValue === expectedValue, actual: propertyValue }
} else if (isOneOfMatcher(expectedValue)) {
Expand Down Expand Up @@ -111,6 +112,7 @@ export async function toHaveElementProperty(
async (iteration) => {
return await executeCommandWithStrategy( {
unresolvedElements: received,
supportsArrayContaining: true,
expectedValues: value,
singleElementCompare: (element, expectedValue: MaybeOneOf<string | number | RegExp | AsymmetricMatcher<string>> | null | undefined) => {
return condition(element, property, expectedValue, options)
Expand Down
1 change: 1 addition & 0 deletions src/matchers/element/toHaveHTML.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export async function toHaveHTML(
async (iteration) => {
const result = await executeCommandWithStrategy( {
unresolvedElements: received,
supportsArrayContaining: true,
expectedValues: expectedValue,
singleElementCompare: (element, expectedValue: MaybeArrayOrOneOf<string | RegExp | AsymmetricMatcher<string>> | undefined) => singleElementCompare(element, expectedValue, options),
context: { isNot, iteration },
Expand Down
6 changes: 6 additions & 0 deletions src/matchers/element/toHaveText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
compareTextOrArray,
enhanceError,
getFeatureFlagValue,
isArrayContainingMatcher,
waitUntil,
} from '../../utils.js'
import type { MaybeArray, MaybeSomeWdioElementOrArrayMaybePromise } from '../../types.js'
Expand Down Expand Up @@ -38,6 +39,7 @@ export async function toHaveText(
return await executeCommandWithStrategy( {
unresolvedElements: received,
expectedValues: expectedValue,
supportsArrayContaining: 'arrayOnly',
singleElementCompare: (element, values: MaybeArray<string | RegExp | AsymmetricMatcher<string>> | ExpectWebdriverIO.OneOfPartialMatcher<string> | undefined) => {
return compareElement(element, values, options)
},
Expand All @@ -50,6 +52,10 @@ export async function toHaveText(
{ wait: options.wait, interval: options.interval }
)

if (isArrayContainingMatcher(expectedValue) && actualText === undefined) {
throw new Error('toHaveText with arrayContaining requires an array of elements')
}

const expected = fillSingleExpectedForElementArray(subject, expectedValue)
const message = enhanceError(subject, expected, actualText, { isNot, isSome }, verb, expectation, '', options)
const result: ExpectWebdriverIO.AssertionResult = {
Expand Down
5 changes: 3 additions & 2 deletions src/util/elementsUtil.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isArrayContainingMatcher } from '../utils.js'
import type { MaybeSomeWdioElementOrArrayMaybePromise, WdioElements, WdioElementsMaybePromise } from '../types.js'

/**
Expand All @@ -9,14 +10,14 @@ import type { MaybeSomeWdioElementOrArrayMaybePromise, WdioElements, WdioElement
* @returns An array containing the expected result if conditions are met, otherwise returns the expected result as-is.
*/
export const wrapExpectedWithArray = (elements: WebdriverIO.Element | WdioElements | unknown, actual: unknown, expected: unknown) => {
if (Array.isArray(elements) && Array.isArray(actual) && !Array.isArray(expected)) {
if (Array.isArray(elements) && Array.isArray(actual) && !Array.isArray(expected) && !isArrayContainingMatcher(expected)) {
expected = Array(actual.length).fill(expected)
}
return expected
}

export const fillSingleExpectedForElementArray = (subject: WebdriverIO.Element | WdioElements | unknown, value: unknown): unknown[] | unknown => {
if (isElementArrayLike(subject) && !Array.isArray(value)) {
if (isElementArrayLike(subject) && !Array.isArray(value) && !isArrayContainingMatcher(value)) {
// When subject has no elements, we should at least represent one for proper failure message!
const fillerlength = subject.length > 0 ? subject.length : 1
return Array(fillerlength).fill(value)
Expand Down
44 changes: 40 additions & 4 deletions src/util/executeCommand.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { equals } from '../jasmineUtils.js'
import { isArrayContainingMatcher } from '../utils.js'
import { isSomeWrapper } from '../matchers/modifiers/some.js'
import type { MaybeSomeWdioElementOrArrayMaybePromise, MaybeArray } from '../types.js'
import { awaitElementOrArray, isElement, isStrictlyElementArray } from './elementsUtil.js'
Expand Down Expand Up @@ -28,27 +30,61 @@ export async function executeCommandWithStrategy<Actual, Expected>( {
singleElementCompare,
context: { isNot, iteration },
strategy = 'NewStrictMultipleElements',
supportsArrayContaining = false,
strictConfiguration = { allowEmptyElements: false, allowArrayWithSingleElement: false }
} :{
unresolvedElements: MaybeSomeWdioElementOrArrayMaybePromise | unknown
expectedValues: MaybeArray<Expected> | unknown
singleElementCompare: (awaitedElement: WebdriverIO.Element, expectedValues: MaybeArray<Expected>, index?: number) => Promise<CompareResult<Actual>>
singleElementCompare: (awaitedElement: WebdriverIO.Element, expectedValues: MaybeArray<Expected> | undefined, index?: number) => Promise<CompareResult<Actual>>
context: { isNot: boolean, iteration: number },
strategy?: StrategyType,
/** Compare collection snapshots using singleElementCompare(element, undefined). 'arrayOnly' rejects scalar subjects. */
supportsArrayContaining?: boolean | 'arrayOnly',
strictConfiguration?: { allowEmptyElements?: boolean, allowArrayWithSingleElement?: boolean }
}
): Promise<StrategyResult<MaybeArray<Actual>>> {
const isSome = isSomeWrapper(unresolvedElements)
const actualReceived = isSome ? unresolvedElements.elements : unresolvedElements

if (supportsArrayContaining && !isSome && isArrayContainingMatcher(expectedValues)) {
const { selector, elements, other } = await awaitElementOrArray(unresolvedElements)
if (elements) {
if (iteration > 0 && isStrictlyElementArray(elements)) {
await refreshElementArray(elements)
}

// Reuse each matcher's value extraction, including command-specific options.
const settled = await Promise.allSettled(Array.from(elements).map(async (element, index) => {
return singleElementCompare(element, undefined, index)
}))
const actual = settled.map((result) => {
if (result.status === 'rejected') {
throw result.reason
}
return result.value.actual
})
return {
subject: elements,
actual,
success: equals(actual, expectedValues),
abort: elements.length === 0 && !isStrictlyElementArray(elements),
}
}
if (!isElement(selector) || supportsArrayContaining === 'arrayOnly') {
return { subject: selector ?? other, actual: undefined, success: !!isNot, abort: true }
}
// A scalar element may itself have an array-valued property.
// SAFETY: Opted-in matchers accept asymmetric expectations; only the collection's sample type differs from Expected.
return { subject: selector, ...await singleElementCompare(selector, expectedValues as MaybeArray<Expected>) }
}

if (strategy === 'LegacyLooseMultipleElements') {
if (isSome) {
throw new Error('some(elements) works only when enabling `useToHaveTextStrictMultiElementsCompareStrategy`')
}
return legacyMultipleElementResultsStrategy(unresolvedElements, expectedValues, singleElementCompare, isNot)
return legacyMultipleElementResultsStrategy(actualReceived, expectedValues, singleElementCompare, isNot)
}

const actualReceived = isSome ? unresolvedElements.elements : unresolvedElements

// Default new strategy for single & multiple element results, which is more consistent and less ambigious than the legacy strategy.
return multipleElementResultsStrategy(actualReceived, expectedValues, singleElementCompare, { isNot, isSome, iteration }, strictConfiguration)
}
Expand Down
4 changes: 2 additions & 2 deletions src/util/formatMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ export const enhanceError = (

if (isJasmineStringAsymmetricMatcher(expected)) {
// With Jest's expect asymetric matcher, it uses a pretty-format plugin for asymetric matcher, but Jasmine's asymmetric matcher doesn't have that!
expected = expected.jasmineToString()
expected = expected.jasmineToString(stringify)
} else if (isElementOrArrayLike(subject) && Array.isArray(expected)) {
expected = expected.map(item => isJasmineStringAsymmetricMatcher(item) ? item.jasmineToString() : item)
expected = expected.map(item => isJasmineStringAsymmetricMatcher(item) ? item.jasmineToString(stringify) : item)
}

// Special formatting for .not with arrays to highlight what matched
Expand Down
16 changes: 16 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,22 @@ export function isAsymmetricMatcher<T>(expected: unknown): expected is WdioAsymm
)
}

/** Identify collection subset matchers by their public Jest/Jasmine display protocol. */
export function isArrayContainingMatcher(expected: unknown): expected is AsymmetricMatcher<unknown[]> {
if (!isAsymmetricMatcher(expected) || typeof expected.asymmetricMatch !== 'function') {
return false
}
if (typeof expected.toString === 'function' && /^Array(Not)?Containing$/.test(expected.toString())) {
return true
}
if ('jasmineToString' in expected && typeof expected.jasmineToString === 'function') {
// Jasmine's formatter accepts a pretty-printer argument; its contents are irrelevant here.
const description = expected.jasmineToString(() => '')
return description === '<jasmine.arrayContaining()>'
}
return false
}

export function isStringContainingMatcherLike(expected: unknown): expected is WdioAsymmetricMatcher<string> | JasmineStringAsymmetricMatcher<string> {
return !!expected && expected.constructor.name === 'StringContaining'
}
Expand Down
Loading
Loading