Skip to content

feat: Support $$() withtoHave element's matchers - #1990

Merged
dprevost-LMI merged 85 commits into
webdriverio:mainfrom
dprevost-LMI:fix-matchers-with-$$
Jul 25, 2026
Merged

feat: Support $$() withtoHave element's matchers#1990
dprevost-LMI merged 85 commits into
webdriverio:mainfrom
dprevost-LMI:fix-matchers-with-$$

Conversation

@dprevost-LMI

@dprevost-LMI dprevost-LMI commented Jan 8, 2026

Copy link
Copy Markdown
Contributor

Partially fixes #512.
Fixes #1717.

Summary

Adds official $$() (element array/elements) support to toHave matchers (toBe done in #2149). Previously, TypeScript signatures allowed arrays (by mistake in this PR), but the implementation didn't support them properly.

Example:

        await expect($$('items')).toBeDisplayed();
        await expect($$('items')).toHaveHTML('<div/>');
        await expect($$('items')).toHaveHTML(['<div/>', '<label/>']);

        // `.not` cases
        await expect($$('items')).not.toBeDisplayed();
        await expect($$('items')).not.toHaveHTML('<div/>');
        await expect($$('items')).not.toHaveHTML(['<div/>', '<label/>']);

Current Behaviour Issues

  • Only toHaveText and toBeElementsArrayOfSize officially support elements
    • toHaveHTML partially implemented support for elements but failed to do so properly
  • Non-awaited $$() or filtered $$().filter() throws errors
  • toHaveText with empty elements incorrectly passes
  • toHaveText doesn't trim text for multiple elements (inconsistent with single element behaviour)
  • toHaveText doesn't do strict and index-based comparison but only loose comparison (kept)
  • Failure message does not show all elements' values (changed)
  • toHaveText, toHaveHTML, toHaveElementClass, toHaveComputedLabel & toHaveComputedRole support an array of expected values with a single element, which must still work

Error handling

  • When $$ returns only one element and we have one expected value, the error message (CHANGED)
Expect $$(`#username`) to have text
Expected: "t"
Received: ""
  • When $$ returns only one element and an array of expectations is passed, the error message (CHANGED)
Expect $$(`#username`) to have text

Expected: ["t", "r"]
Received: ""
  • When having multiple elements and multiple expected values, we see the following (CHANGED)
Expect $$(`label`) to have text

- Expected  - 3
+ Received  + 1

  Array [
-   Array [
    "Username",
-     "Password1",
-   ],
+   "Password",
  ]

Note: All the above have been changed to show all the elements' values and not just those not matching

Official $$() Support

This PR adds official support for toHave element matchers.

⚠️ While $$() support may incidentally enable expect() to work with multi-remote, this is not intended and may break at any time. Official multi-remote support is tracked here and is not yet available.

Types Support

  • ChainablePromiseArray, the non-awaited case
  • ElementArray, the awaited case
  • Element[], the filtered case

Behavior

The following must pass when all elements have the HTML; otherwise, it fails.

        await expect($$('items')).toHaveHTML('<div/>');
        await expect($$('items')).toHaveHTML(['<div/>', '<label/>']);
  • For toHave matchers, you can provide a single expected value or an array; strict array comparison is used.
  • Options like StringOptions, HTMLOptions, ToBeDisplayedOptions apply to the whole array (not per element).
  • Only NumberNumber can be provided as an array, but the former NumberOptions is not supported.

Array Comparison Behaviour

  • With a single expected value, all elements must strictly match (for text, trimming is the default unless { trim: false }).
  • With an array of expected values, each element is compared by index; differing array lengths or mismatches cause failure.
  • Except for toHaveText (deprecated), elements are not compared to any value in the expected array—only by index.
  • For number options, strict matching still applies according to the NumberOptions rules.

isNot

The following must pass when all elements are not displayed/not have the text; otherwise, it fails.

        await expect($$('items')).not.toBeDisplayed();
        await expect($$('items')).not.toHaveHTML('<div/>');
        await expect($$('items')).not.toHaveHTML(['<div/>', '<label/>']);

Edge cases

No elements found

When no elements are found, we fail (nearly) at all times with or without .not, even if the expected is an empty array.

  • toBeElementsArrayOfSize(0) & toExists are the only ones supporting empty elements without failures

expect.arrayContaining

Only toHaveText will do a containing array behaviour with the following

        await expect(await $$('label')).toHaveText(['Username', 'Password']);

We should consider deprecating the above for expect.arrayContaining and supporting it, which is not the case at all

        await expect(await $$('label')).toHaveText(expect.arrayContaining(['Username', 'Password']));

Error handling

Below are examples of colour failures.

  • We can see cases for multiple elements for the toHaveText and toBeDisplayed matchers
  • With .not
    • toBe are handled by adding not in the values
    • For toHave matchers, a more complex method was used to highlight those actually matching (red highlight)
image

BREAKING

  • Removed deprecated toHaveClassContaining matchers.
  • executeCommand removed; could be brought back if anyone used it
  • aliasFN removed

Future Considerations

  • For toBeElementsArrayOfSize.ts, consider updating the array in the non-awaited case by awaiting it
  • For toHaveElementProperty
    • Use deep-equality to support types like objects & Arrays
    • As toHaveAttribute supports properly optional expected value for property existence
  • Use anyOf() or oneOf() for the OR aka containing behaviour existing on a single element and on toHaveText.
  • Use any() for element existence with $$()
  • Consider using the refreshElement approach when the elements and expected values length do not match or when there are failures?
  • Add an earlier abort feature for array mismatch
let abort = false
const pass = await waitUntil(
    async () => {
        const result = await executeCommandWithStrategy(...)
        elements = result.subject
        actualPropertyValue = result.actual
        abort = result.abort ?? false
        return result.success
    },
    isNot,
    { wait: options.wait, interval: options.interval, shouldAbort: () => abort }
)

@dprevost-LMI
dprevost-LMI force-pushed the fix-matchers-with-$$ branch 2 times, most recently from 811216d to 62a200b Compare January 10, 2026 03:23
Comment thread .github/workflows/test.yml
@dprevost-LMI dprevost-LMI changed the title fix: Fix matchers not working with $$ aka ElementArray feat: Support $$ with all matchers Jan 11, 2026
@dprevost-LMI dprevost-LMI changed the title feat: Support $$ with all matchers feat: Support $$() with all matchers Jan 11, 2026
@dprevost-LMI dprevost-LMI changed the title feat: Support $$() with all matchers feat: Support $$() with all element's matchers Jan 11, 2026
Comment thread src/util/elementsUtil.ts
Comment thread src/utils.ts Outdated
Comment thread src/matchers/element/toHaveChildren.ts Outdated
Comment thread src/util/executeCommand.ts Outdated
@dprevost-LMI
dprevost-LMI force-pushed the fix-matchers-with-$$ branch 14 times, most recently from f8f6ba0 to 78a6cf0 Compare January 20, 2026 23:18
@dprevost-LMI
dprevost-LMI force-pushed the fix-matchers-with-$$ branch 6 times, most recently from cd23183 to aec39c2 Compare January 24, 2026 23:01
@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Type-Definition vs. Implementation Analysis

1. toHaveAttr / toHaveClass (deprecated) — types don't expose $$() variant

Both are declared with only one Fn argument to FnWhenElementOrArrayLike, so FnArray = FnElement (defaults apply):

// types/expect-webdriverio.d.ts
toHaveAttr: FnWhenElementOrArrayLike<ActualT, (
    attribute: string,
    value?: string | RegExp | ExpectWebdriverIO.PartialMatcher<string>,
    options?: ExpectWebdriverIO.StringOptions
) => Promise<void>>  // No FnArray provided → same signature used for $$ case

The underlying implementation (toHaveAttribute) fully supports MaybeArray<string> for $$(), but the types for toHaveAttr/toHaveClass won't accept it — $$().toHaveAttr('attr', ['v1', 'v2']) is a TypeScript error. Minor since deprecated, but the capability is silently hidden.


2. toHaveComputedLabel / toHaveComputedRole — inner function uses HTMLOptions instead of StringOptions

// src/matchers/element/toHaveComputedLabel.ts
async function singleElementCompare(
    element: WebdriverIO.Element,
    label: MaybeArray<string | RegExp | AsymmetricMatcher<string>> | undefined,
    options: ExpectWebdriverIO.HTMLOptions  // ← HTMLOptions, not StringOptions
)

The public signature correctly takes StringOptions, but the private singleElementCompare widens it to HTMLOptions, which adds includeSelectorTag. That extra option would be silently ignored since getComputedLabel()/getComputedRole() don't use it. The fix is to use StringOptions in both inner function signatures.


3. toHaveAttribute — deprecated undefined overload exposed for $$() in types, but restricted to $() in implementation

Types: both the element and elements blocks expose (attribute, undefined, options?) as a deprecated overload.

Implementation:

export async function toHaveAttribute(
    received: WdioElementMaybePromise,  // ← element only, not array
    attribute: string,
    value: undefined,
    options?: ExpectWebdriverIO.StringOptions
): Promise<AssertionResult>

At runtime this still works (the broad implementation overload catches it), but the d.ts implies the deprecated undefined form is intentionally supported for $$(), when it isn't explicitly. Consider removing this overload from the elements $$() block in the types.


4. matchNumber OR-semantics are effectively dead code

matchNumber supports OR behavior when passed an array:

// src/util/numberOptionsUtil.ts
if (Array.isArray(expected)) {
    return expected.some((matcher) => matcher.match(actual))  // OR semantics
}

This branch is unreachable in practice:

  • For $(): executeCommandWithStrategy with allowArrayWithSingleElement: false (the default, also used by toHaveChildren) sets forceFailure = true and passes undefined when the expected is an array → condition gets undefined, the array branch is never hit.
  • For $$(): the strategy distributes one indexed NumberMatcher per element → singleElementCompare always receives a scalar NumberMatcher, hitting the instanceof NumberMatcher branch.

The array/OR path can only be triggered by a caller that bypasses the strategy — which doesn't exist publicly. Consider either removing the array branch or documenting a concrete entry point.


5. toHaveChildren condition function type is wider than necessary

// src/matchers/element/toHaveChildren.ts
async function condition(el: WebdriverIO.Element, expectedValue: MaybeArray<NumberMatcher> | undefined) {
    ...
    result: matchNumber(children?.length, expectedValue),  // expectedValue is always a scalar here

Because of finding #4, singleElementCompare always receives a single NumberMatcher per element call, never an array. The type should be NumberMatcher | undefined. Compare to toHaveWidth/toHaveHeight which correctly type their inner condition param as NumberMatcher | undefined. The current wider type misleads readers into thinking an array can arrive here.


6. "Soon deprecated" array overload for single $() — semantics undocumented

toHaveHTML, toHaveComputedLabel, toHaveComputedRole, and toHaveText all expose an element $() overload accepting Array<string | ...> labeled "soon deprecated":

/** soon deprecated to replace by oneOf() or anyOf() when available */
(text: Array<string | RegExp | ...>, options?): Promise<void>

For single elements (allowArrayWithSingleElement: true), the entire array is forwarded to compareTextOrArray. The matching semantics (OR vs. ALL-must-match) are not documented in the JSDoc. A user passing $('el').toHaveHTML(['<div/>', '<label/>']) has no indication whether the element's HTML must match one or all of those values. This is especially confusing because the new $$() behavior is strictly index-based. The deprecation message should clarify the current behavior before it's removed.


Summary

Matcher Issue Severity
toHaveAttr / toHaveClass No FnArray → types hide MaybeArray support for $$() Low (deprecated)
toHaveComputedLabel / toHaveComputedRole Inner singleElementCompare uses HTMLOptions instead of StringOptions Low (silent)
toHaveAttribute Deprecated undefined overload appears in $$() types but not in impl Low (docs)
matchNumber Array OR-branch is unreachable via any public code path Medium (dead code)
toHaveChildren condition Param typed as MaybeArray<NumberMatcher> when it always receives a scalar Low (type accuracy)
toHaveHTML / toHaveComputedLabel / toHaveComputedRole / toHaveText Single-element array overload semantics undocumented in the deprecation notice Medium (UX clarity)

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Type Definition vs Implementation Analysis

wrapExpectedWithArray — False Alarm

toHaveWidth and toHaveHeight import wrapExpectedWithArray from ../../util/elementsUtil.js while other matchers import from ../../utils.js. This is cosmetic onlyutils.ts just re-exports the exact same function from elementsUtil.ts:

// src/utils.ts
import { wrapExpectedWithArray } from './util/elementsUtil.js'
export { ..., wrapExpectedWithArray }

No functional difference, but prefer the utils.js path for consistency.


Bug 1: toHaveComputedLabel & toHaveComputedRole — Wrong options type internally

Both matchers accept StringOptions externally but their singleElementCompare internal function is annotated with HTMLOptions:

// toHaveComputedLabel.ts (same pattern in toHaveComputedRole.ts)
async function singleElementCompare(
    element: WebdriverIO.Element,
    label: MaybeArray<...> | undefined,
    options: ExpectWebdriverIO.HTMLOptions  // ← should be StringOptions
)

export async function toHaveComputedLabel(
    ...
    options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS  // ← StringOptions
)

StringOptions is not assignable to HTMLOptions (which adds includeSelectorTag), so this is a genuine type error in the internal function signatures. Fix: change HTMLOptionsStringOptions in both internal singleElementCompare functions.


Bug 2: toHaveAttribute — Array silently loses its type

When the $$() path is taken with an array value, the implementation casts it to scalar:

let value: string | RegExp | AsymmetricMatcher<string> | undefined  // ← no MaybeArray

} else {
    value = valueOrOptions as string | RegExp | AsymmetricMatcher<string>  // ← array silently dropped
}

The array survives at runtime and is correctly handled by toHaveAttributeAndValue (which accepts MaybeArray), but the intermediate type is wrong. Fix:

let value: MaybeArray<string | RegExp | AsymmetricMatcher<string>> | undefined

Bug 3: toBeElementsArrayOfSize — Phantom return type in type definition

toBeElementsArrayOfSize: FnWhenElementArrayLike<ActualT, {
    (size: number | ExpectWebdriverIO.NumberMatcher, options?): Promise<void> & Promise<WebdriverIO.ElementArray>,

The & Promise<WebdriverIO.ElementArray> intersection is impossible to satisfy — a promise cannot resolve to both void and WebdriverIO.ElementArray. The implementation returns { pass, message } (an AssertionResult); the array mutation is a side effect on received, not the resolved value. This should simply be Promise<void>.


Bug 4: toHaveChildren condition function — overly broad parameter type

async function condition(el: WebdriverIO.Element, expectedValue: MaybeArray<NumberMatcher> | undefined) {
    // ...
    result: matchNumber(children?.length, expectedValue),  // ← MaybeArray passed but scalar expected

executeCommandWithStrategy distributes the array and calls singleElementCompare with one value per element — so at runtime expectedValue is always a single NumberMatcher | undefined, never an array. Every other matcher narrows the condition function parameter to the scalar type. Should be NumberMatcher | undefined.


Bug 5: toHaveText — Inconsistent error message construction when strict strategy is active

When isNewStrictCompare is true, toHaveText builds the error message differently from all other matchers:

// toHaveText.ts — uses fillSingleExpectedForElementArray
const expected = fillSingleExpectedForElementArray(subject, expectedValue)
const message = enhanceError(subject, expected, actualText, ...)
// toHaveHTML, toHaveAttribute, toHaveComputedLabel, etc. — uses wrapExpectedWithArray
const expectedValues = wrapExpectedWithArray(elements, actualHTML, expectedValue)
const message = enhanceError(elements, expectedValues, actualHTML, ...)

The key difference: fillSingleExpectedForElementArray fills based on subject.length (using 1 as fallback for empty elements), while wrapExpectedWithArray fills based on actual.length. With the new strict strategy producing per-element actual arrays, toHaveText should use wrapExpectedWithArray for consistency — especially since subject and actual are set from commandResult.subject/commandResult.actual in the same way as other matchers.


Minor: compareTextWithArray trim inconsistency (tracked for v6, worth documenting)

compareText defaults trim: true, but compareTextWithArray defaults trim: false:

// utils.ts
export const compareTextWithArray = (
    actual: string,
    expectedArray: Array<...>,
    {
        trim = false,  // ← TODO: "To review in v6.0.0 and make it consistent for both single and array"
    }: ExpectWebdriverIO.StringOptions

This affects toHaveText when an array of expected values is passed to a single element ($()) — trimming is silently skipped, inconsistent with the default behavior when a single string is passed.


Summary Table

Matcher Issue Severity
toHaveComputedLabel & toHaveComputedRole Internal singleElementCompare typed as HTMLOptions instead of StringOptions Medium – real type error
toHaveAttribute value variable loses MaybeArray type after assignment Low – runtime ok, type-unsafe
toBeElementsArrayOfSize Return type Promise<void> & Promise<WebdriverIO.ElementArray> is unsatisfiable Medium – misleading .d.ts
toHaveChildren condition() typed as MaybeArray<NumberMatcher> but only ever receives scalar Low – overly broad
toHaveText (strict mode) Uses fillSingleExpectedForElementArray instead of wrapExpectedWithArray for error messages Medium – inconsistent failure output
toHaveWidth, toHaveHeight Import wrapExpectedWithArray directly from elementsUtil.js instead of via utils.js Trivial – cosmetic

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Here's a comprehensive analysis of the discrepancies between the type definitions and implementations:


1. toHaveWidth / toHaveHeight — Missing deprecated NumberOptions overload in types

The implementation declares a deprecated NumberOptions overload:

// toHaveWidth.ts / toHaveHeight.ts
export async function toHaveWidth(
    received: WdioElementMaybePromise,
    expectedValue: ExpectWebdriverIO.NumberOptions,  // still in implementation
    ...
)

But the type definition omits it entirely:

toHaveWidth: FnWhenElementOrArrayLike<ActualT, {
    (width: number | ExpectWebdriverIO.NumberMatcher, options?: ...): Promise<void>
    // ❌ no NumberOptions overload here
}>

Contrast this with toHaveChildren, which correctly retains it:

(expectedValue: ExpectWebdriverIO.NumberOptions, options?: ...): Promise<void>;  // deprecated

This inconsistency means users passing a legacy NumberOptions object to toHaveWidth/toHaveHeight will get a TypeScript error, while the same pattern on toHaveChildren compiles fine. Either drop the deprecated impl overload too, or add the type overload for consistency until v6.


2. toBeElementsArrayOfSize — Incorrect return type in type definition

The type definition declares:

(size: number | ExpectWebdriverIO.NumberMatcher, options?): Promise<void> & Promise<WebdriverIO.ElementArray>

The implementation returns:

// toBeElementsArrayOfSize.ts
const result: ExpectWebdriverIO.AssertionResult = { pass, message: (): string => message }
return result

ExpectWebdriverIO.AssertionResult ({ pass: boolean, message: () => string }) is structurally incompatible with Promise<WebdriverIO.ElementArray>. The & Promise<WebdriverIO.ElementArray> part appears to be a leftover from an old design intention (the received.push(...) mutation at the bottom of the impl), not reflecting the actual resolved value. The return type in the d.ts should be just Promise<void>.


3. toHaveComputedLabel / toHaveComputedRole — Internal singleElementCompare uses HTMLOptions instead of StringOptions

// toHaveComputedLabel.ts
async function singleElementCompare(
    element: WebdriverIO.Element,
    label: ...,
    options: ExpectWebdriverIO.HTMLOptions  // ← HTMLOptions (has includeSelectorTag)
) { ... }

// But the exported function accepts:
export async function toHaveComputedLabel(
    ...,
    options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS  // ← StringOptions
)

The type definition correctly says StringOptions. The mismatch compiles only because HTMLOptions extends StringOptions and includeSelectorTag is optional, making StringOptions structurally assignable. Semantically wrong though — computed labels have nothing to do with includeSelectorTag. Same issue exists in toHaveComputedRole.ts.


4. toHaveElementProperty — Stale TODO comment contradicts the actual implementation

Inside toHaveElementProperty.ts:

async function condition(
    el: WebdriverIO.Element,
    property: string,
    expectedValue: string | number | RegExp | AsymmetricMatcher<string> | null | undefined,
    // TODO: review if an array of expected values should be supported for this matcher similarly as other matchers
    ...
)

But the exported overloads already support MaybeArray for the $$() case:

export async function toHaveElementProperty(
    received: WdioElementsMaybePromise,
    property: string,
    value: MaybeArray<string | number | RegExp | AsymmetricMatcher<string>>,
    ...
)

And executeCommandWithStrategy already handles the per-element array splitting internally. The TODO is stale/misleading — array support is already implemented. This should be removed.


5. toHaveAttr (deprecated) and toHaveClass (deprecated) — Type exposes narrower signature than implementation

Both are defined as single-type FnWhenElementOrArrayLike<ActualT, Fn> (no second type argument for the $$() variant), hiding MaybeArray support for arrays:

// types
toHaveAttr: FnWhenElementOrArrayLike<ActualT, (
    attribute: string,
    value?: string | RegExp | PartialMatcher<string>,  // ← no MaybeArray for $$()
    ...
) => Promise<void>>

But their implementations delegate to the full-featured functions:

export const toHaveAttr = toHaveAttribute  // supports MaybeArray for $$()
export function toHaveClass(...args) { return toHaveElementClass.call(...) }  // same

So expect($$(...)).toHaveAttr('class', ['a','b']) works at runtime but fails TypeScript. Since both are deprecated until v6 removal, this is low priority, but if they're being deprecated-in-place (not removed yet), giving the correct type is preferable.


6. Minor: Typo in toHaveHref.ts JSDoc

/**
 * Elemment $() APi   // ← "Elemment" and "APi"
 */

Summary Table

Matcher Issue Severity
toHaveWidth / toHaveHeight Missing deprecated NumberOptions overload in type def (inconsistent with toHaveChildren) Medium
toBeElementsArrayOfSize Return type Promise<void> & Promise<WebdriverIO.ElementArray> doesn't match actual AssertionResult Medium
toHaveComputedLabel / toHaveComputedRole Internal helper typed as HTMLOptions but public API is StringOptions Low
toHaveElementProperty Stale TODO — MaybeArray already supported via executeCommandWithStrategy Low
toHaveAttr / toHaveClass Type hides MaybeArray for $$() even though impl supports it Low (deprecated)
toHaveHref.ts Typo in JSDoc Trivial

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

Type Definition vs Implementation Analysis

wrapExpectedWithArray — False Alarm

toHaveWidth and toHaveHeight import wrapExpectedWithArray from ../../util/elementsUtil.js while other matchers import from ../../utils.js. This is cosmetic onlyutils.ts just re-exports the exact same function from elementsUtil.ts:

// src/utils.ts
import { wrapExpectedWithArray } from './util/elementsUtil.js'
export { ..., wrapExpectedWithArray }

No functional difference, but prefer the utils.js path for consistency.

Bug 1: toHaveComputedLabel & toHaveComputedRole — Wrong options type internally

Both matchers accept StringOptions externally but their singleElementCompare internal function is annotated with HTMLOptions:

// toHaveComputedLabel.ts (same pattern in toHaveComputedRole.ts)
async function singleElementCompare(
    element: WebdriverIO.Element,
    label: MaybeArray<...> | undefined,
    options: ExpectWebdriverIO.HTMLOptions  // ← should be StringOptions
)

export async function toHaveComputedLabel(
    ...
    options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS  // ← StringOptions
)

StringOptions is not assignable to HTMLOptions (which adds includeSelectorTag), so this is a genuine type error in the internal function signatures. Fix: change HTMLOptionsStringOptions in both internal singleElementCompare functions.

Bug 2: toHaveAttribute — Array silently loses its type

When the $$() path is taken with an array value, the implementation casts it to scalar:

let value: string | RegExp | AsymmetricMatcher<string> | undefined  // ← no MaybeArray

} else {
    value = valueOrOptions as string | RegExp | AsymmetricMatcher<string>  // ← array silently dropped
}

The array survives at runtime and is correctly handled by toHaveAttributeAndValue (which accepts MaybeArray), but the intermediate type is wrong. Fix:

let value: MaybeArray<string | RegExp | AsymmetricMatcher<string>> | undefined

Bug 3: toBeElementsArrayOfSize — Phantom return type in type definition

toBeElementsArrayOfSize: FnWhenElementArrayLike<ActualT, {
    (size: number | ExpectWebdriverIO.NumberMatcher, options?): Promise<void> & Promise<WebdriverIO.ElementArray>,

The & Promise<WebdriverIO.ElementArray> intersection is impossible to satisfy — a promise cannot resolve to both void and WebdriverIO.ElementArray. The implementation returns { pass, message } (an AssertionResult); the array mutation is a side effect on received, not the resolved value. This should simply be Promise<void>.

Bug 4: toHaveChildren condition function — overly broad parameter type

async function condition(el: WebdriverIO.Element, expectedValue: MaybeArray<NumberMatcher> | undefined) {
    // ...
    result: matchNumber(children?.length, expectedValue),  // ← MaybeArray passed but scalar expected

executeCommandWithStrategy distributes the array and calls singleElementCompare with one value per element — so at runtime expectedValue is always a single NumberMatcher | undefined, never an array. Every other matcher narrows the condition function parameter to the scalar type. Should be NumberMatcher | undefined.

Bug 5: toHaveText — Inconsistent error message construction when strict strategy is active

When isNewStrictCompare is true, toHaveText builds the error message differently from all other matchers:

// toHaveText.ts — uses fillSingleExpectedForElementArray
const expected = fillSingleExpectedForElementArray(subject, expectedValue)
const message = enhanceError(subject, expected, actualText, ...)
// toHaveHTML, toHaveAttribute, toHaveComputedLabel, etc. — uses wrapExpectedWithArray
const expectedValues = wrapExpectedWithArray(elements, actualHTML, expectedValue)
const message = enhanceError(elements, expectedValues, actualHTML, ...)

The key difference: fillSingleExpectedForElementArray fills based on subject.length (using 1 as fallback for empty elements), while wrapExpectedWithArray fills based on actual.length. With the new strict strategy producing per-element actual arrays, toHaveText should use wrapExpectedWithArray for consistency — especially since subject and actual are set from commandResult.subject/commandResult.actual in the same way as other matchers.

Minor: compareTextWithArray trim inconsistency (tracked for v6, worth documenting)

compareText defaults trim: true, but compareTextWithArray defaults trim: false:

// utils.ts
export const compareTextWithArray = (
    actual: string,
    expectedArray: Array<...>,
    {
        trim = false,  // ← TODO: "To review in v6.0.0 and make it consistent for both single and array"
    }: ExpectWebdriverIO.StringOptions

This affects toHaveText when an array of expected values is passed to a single element ($()) — trimming is silently skipped, inconsistent with the default behavior when a single string is passed.

Summary Table

Matcher Issue Severity
toHaveComputedLabel & toHaveComputedRole Internal singleElementCompare typed as HTMLOptions instead of StringOptions Medium – real type error
toHaveAttribute value variable loses MaybeArray type after assignment Low – runtime ok, type-unsafe
toBeElementsArrayOfSize Return type Promise<void> & Promise<WebdriverIO.ElementArray> is unsatisfiable Medium – misleading .d.ts
toHaveChildren condition() typed as MaybeArray<NumberMatcher> but only ever receives scalar Low – overly broad
toHaveText (strict mode) Uses fillSingleExpectedForElementArray instead of wrapExpectedWithArray for error messages Medium – inconsistent failure output
toHaveWidth, toHaveHeight Import wrapExpectedWithArray directly from elementsUtil.js instead of via utils.js Trivial – cosmetic

@greptileai, can you review all the above since I fixed a few?

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

@greptileai, can you expand on those two: why you are flagging them and what the proposed fixes are

toHaveText with a one-element $$() produces a misleading diff because fillSingleExpectedForElementArray wraps the expected into an array while the legacy strategy still returns a scalar actual; and the removal of executeCommand from the public utils re-export is not listed as a breaking change.

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Now I have the full picture. Let me give you specific answers for both.


Issue 1: Misleading diff for toHaveText on a 1-element $$()

The problem is a shape mismatch between what legacyMultipleElementResultsStrategy returns as actual and what fillSingleExpectedForElementArray builds for expected.

In src/util/executeCommand.ts, when the resolved selector has exactly 1 element, the legacy strategy returns a scalar:

// legacyMultipleElementResultsStrategy
actual: results.length === 1 ? results[0].value : results.map(({ value }) => value),
//                ^^ scalar ""   when 1 element

But in src/matchers/element/toHaveText.ts, fillSingleExpectedForElementArray sees the subject is still an ElementArray (even with 1 item) and wraps the expected into an array:

// fillSingleExpectedForElementArray
if (isElementArrayLike(subject) && !Array.isArray(value)) {
    return Array(fillerlength).fill(value) // ["t"] for 1-element $$()
}

So enhanceError receives expected = ["t"] vs actual = "" — comparing an array to a scalar — which printDiffOrStringify renders as a confusing structural diff instead of a simple string comparison.

The NewStrictMultipleElements strategy doesn't have this problem because it always returns an array: results.map(({ value }) => value).

Fix: Remove the scalar special-case in legacyMultipleElementResultsStrategy so it always returns an array for the multi-element path:

actual: results.map(({ value }) => value)

Or, in toHaveText.ts, skip fillSingleExpectedForElementArray when using the legacy strategy — but the former is cleaner since the legacy path is already in the multi-element branch by definition.


Issue 2: executeCommand removal is an unannounced breaking change

On main, src/utils.ts explicitly re-exports executeCommand:

// main branch — src/utils.ts
export {
    aliasFn, compareNumbers, enhanceError, executeCommand,
    executeCommandBe, waitUntil, wrapExpectedWithArray
}

On this PR branch it's dropped entirely — not in src/utils.ts's re-export, and not re-exported from src/util/executeCommand.ts via the public surface either. Since src/index.ts does export * as utils from './utils.js', any consumer who wrote:

import { utils } from 'expect-webdriverio'
utils.executeCommand(...)

...will get a runtime error with no type-level warning. The PR description only lists toHaveClassContaining as a breaking removal; this one is missing. It should either be re-exported (even as @deprecated) or explicitly called out as a breaking change in the changelog/migration notes.

@dprevost-LMI

dprevost-LMI commented Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Now I have the full picture. Let me give you specific answers for both.

Issue 1: Misleading diff for toHaveText on a 1-element $$()

The problem is a shape mismatch between what legacyMultipleElementResultsStrategy returns as actual and what fillSingleExpectedForElementArray builds for expected.

In src/util/executeCommand.ts, when the resolved selector has exactly 1 element, the legacy strategy returns a scalar:

// legacyMultipleElementResultsStrategy
actual: results.length === 1 ? results[0].value : results.map(({ value }) => value),
//                ^^ scalar ""   when 1 element

But in src/matchers/element/toHaveText.ts, fillSingleExpectedForElementArray sees the subject is still an ElementArray (even with 1 item) and wraps the expected into an array:

// fillSingleExpectedForElementArray
if (isElementArrayLike(subject) && !Array.isArray(value)) {
    return Array(fillerlength).fill(value) // ["t"] for 1-element $$()
}

So enhanceError receives expected = ["t"] vs actual = "" — comparing an array to a scalar — which printDiffOrStringify renders as a confusing structural diff instead of a simple string comparison.

The NewStrictMultipleElements strategy doesn't have this problem because it always returns an array: results.map(({ value }) => value).

Fix: Remove the scalar special-case in legacyMultipleElementResultsStrategy so it always returns an array for the multi-element path:

actual: results.map(({ value }) => value)

Or, in toHaveText.ts, skip fillSingleExpectedForElementArray when using the legacy strategy — but the former is cleaner since the legacy path is already in the multi-element branch by definition.

Issue 2: executeCommand removal is an unannounced breaking change

On main, src/utils.ts explicitly re-exports executeCommand:

// main branch — src/utils.ts
export {
    aliasFn, compareNumbers, enhanceError, executeCommand,
    executeCommandBe, waitUntil, wrapExpectedWithArray
}

On this PR branch it's dropped entirely — not in src/utils.ts's re-export, and not re-exported from src/util/executeCommand.ts via the public surface either. Since src/index.ts does export * as utils from './utils.js', any consumer who wrote:

import { utils } from 'expect-webdriverio'
utils.executeCommand(...)

...will get a runtime error with no type-level warning. The PR description only lists toHaveClassContaining as a breaking removal; this one is missing. It should either be re-exported (even as @deprecated) or explicitly called out as a breaking change in the changelog/migration notes.

@greptileai, First issue: I pushed a fix. Second issue: it does not matter; I consider this one internal and should not be exposed to the public. Let's skip that in your report

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

The fix looks correct — legacyMultipleElementResultsStrategy now unconditionally returns results.map(({ value }) => value), so actual is always an array and the shape mismatch with fillSingleExpectedForElementArray's expected is resolved.

On executeCommand: agreed, treating it as internal is reasonable. The previous export via utils.* was incidental, not a deliberate public API contract. No action needed on that one.

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

@greptileai, expand on the below and propose some fixes

The two areas worth watching are the OR-vs-AND semantic asymmetry between
(
)
+
a
r
r
a
y
e
x
p
e
c
t
e
d
a
n
d
$() + same array, and the internal type cast in toHaveElementProperty that hides the MaybeArray nature of the value at compile time. Neither causes a runtime error in the current test suite.

Files Needing Attention: src/util/executeCommand.ts (forceFailure / isNot interaction), src/matchers/element/toHaveHTML.ts and similarly configured matchers (allowArrayWithSingleElement semantics), src/matchers/element/toHaveElementProperty.ts (MaybeArray cast)

@greptile-apps

greptile-apps Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Here are all three issues broken down with concrete fixes:


Issue 1: forceFailure / isNot interaction — two distinct bugs

Understanding waitUntil first:

const result = isNot !== (await condition())
// breaks when result is true; retries when false

With isNot=true, waitUntil breaks when condition() returns false, then returns !isNot = false.

Bug A — silent pass on invalid input (single element branch)

In multipleElementResultsStrategy, when allowArrayWithSingleElement=false and an array is passed to a single element:

if (isElement(selector)) {
    let forceFailure = false
    if (!allowArrayWithSingleElement && Array.isArray(expectedValues)) {
        expectedValues = undefined
        forceFailure = true
    }
    const compareResult = await singleElementCompare(selector, expectedValues)
    return {
        subject,
        success: forceFailure ? false : compareResult.result,  // returns false
        actual: compareResult.value,
    }
}

With isNot=true and success=false:

  • waitUntil sees isNot !== falsetruebreaks immediately
  • Returns !isNot = falsepass = false
  • Jest evaluates pass=false, isNot=trueassertion PASSES

So expect($('el')).not.toHaveElementProperty('name', ['a', 'b']) silently passes. Passing an array to a single-element matcher is invalid input and should always fail hard.

Bug B — full timeout delay before correctly failing (array length mismatch)

In the multiple-element path:

let forceFailure = false
if (Array.isArray(expectedValues) && expectedValues.length !== selector.length) {
    forceFailure = true
}

return {
    subject,
    success: isNot
        ? !(!forceFailure && isNotEmpty && isAllFalse(results))  // = !(false && ...) = true when forceFailure=true
        : (!forceFailure && isNotEmpty && isAllTrue(results)),
    ...
}

When forceFailure=true and isNot=true: success=true. waitUntil keeps retrying since it needs false to break. It eventually times out and returns isNot=truepass=true → Jest: pass=true, isNot=truefails correctly, but only after the full timeout wait.

Fix for both — surface an abort flag (already tracked in Future Considerations)

Add to StrategyResult:

export type StrategyResult<T> = {
    subject: ...,
    success: boolean,
    actual: MaybeArray<T> | undefined,
    abort?: boolean  // signal immediate unconditional failure
}

In the single-element forceFailure branch:

return {
    subject,
    success: false,
    abort: true,   // bypass isNot — this is always a failure
    actual: compareResult.value,
}

In the array length mismatch branch — replace the isNot-dependent expression entirely:

return {
    subject,
    success: false,  // always false; isNot is irrelevant for a structural mismatch
    abort: true,
    actual: results.map(({ value }) => value),
}

Then expose shouldAbort in waitUntil as already drafted in the PR:

export const waitUntil = async (
    condition: () => Promise<boolean>,
    isNot = false,
    { wait = ..., interval = ..., shouldAbort = () => false } = {}
) => {
    const conditionResult = await condition()
    if (shouldAbort()) {
        return false  // always fail, regardless of isNot
    }
    const result = isNot !== conditionResult
    // ...
}

Issue 2: OR-vs-AND semantic asymmetry (allowArrayWithSingleElement)

For matchers like toHaveHTML (allowArrayWithSingleElement: true), when a $$() call resolves to one element, the full array is passed to singleElementCompare, which calls compareTextWithArray using Array.some()OR semantics. When $$() resolves to two or more elements, the array is distributed by index — AND+index semantics.

The same written assertion has two different semantics depending on a runtime outcome:

// $$('items') finds 1 element → OR: passes if element is '<div/>' OR '<label/>'
await expect($$('items')).toHaveHTML(['<div/>', '<label/>'])

// $$('items') finds 2 elements → AND+index: [0] must be '<div/>' AND [1] must be '<label/>'
await expect($$('items')).toHaveHTML(['<div/>', '<label/>'])

A test that's green with 1 element silently changes semantics when the DOM has 2 elements.

Fix

Track whether the call came from a multi-element context with an additional configuration flag:

strictConfiguration?: {
    allowEmptyElements?: boolean,
    allowArrayWithSingleElement?: boolean,
    isMultiElementContext?: boolean  // true when received is $$() / ElementArray
}

In the isElement(selector) branch, apply index-based logic when isMultiElementContext=true:

if (isElement(selector)) {
    let forceFailure = false
    const treatAsMulti = strictConfiguration?.isMultiElementContext

    if (Array.isArray(expectedValues)) {
        if (treatAsMulti) {
            // $$() that happened to find 1 element — apply AND+index: only expectedValues[0] is valid
            if (expectedValues.length !== 1) {
                forceFailure = true
                expectedValues = undefined
            } else {
                expectedValues = expectedValues[0]  // extract scalar → no OR behavior
            }
        } else if (!allowArrayWithSingleElement) {
            expectedValues = undefined
            forceFailure = true
        }
    }
    // ...
}

Matchers like toHaveHTML would then pass isMultiElementContext: true when the received type is $$(). This ensures consistent AND+index semantics regardless of how many elements are found at runtime.


Issue 3: MaybeArray cast hiding the array type in toHaveElementProperty

let value: string | number | RegExp | AsymmetricMatcher<string> | null | undefined
// ...
} else {
    value = valueOrOptions as string | number | RegExp | AsymmetricMatcher<string> | null
    //       ^^^^ cast strips Array<...> at compile time, but at runtime value CAN be an array
}

The Elements $$() overload explicitly accepts MaybeArray<...>, so at runtime value will be an array when called from the array path. The cast makes TypeScript treat it as a scalar. Two concrete consequences:

  1. The condition function has no array in its signature — a future refactor that passes value directly to condition (bypassing executeCommandWithStrategy's index extraction) won't be caught by the compiler.
  2. The value === undefined check further down is fine, but the wrapExpectedWithArray call in the else branch receives what TypeScript thinks is a scalar but is actually an array — the formatter may produce unexpected output for the error message.

Fix

Widen value to match the actual runtime type:

let value: MaybeArray<string | number | RegExp | AsymmetricMatcher<string>> | null | undefined
// ...
} else {
    value = valueOrOptions as MaybeArray<string | number | RegExp | AsymmetricMatcher<string>> | null
}

The condition function itself stays safe since executeCommandWithStrategy extracts the index-based scalar before calling it — but having the correct type at the declaration site ensures future changes can't silently bypass the array handling.


Summary:

Issue File Severity Impact
forceFailure + isNot=true → silent pass (single elem) executeCommand.ts 🔴 Bug .not on invalid array input passes instead of fails
forceFailure + isNot=true → full timeout wait (length mismatch) executeCommand.ts 🟡 UX Correct result, but wastes the full wait duration before failing
OR-vs-AND runtime flip via allowArrayWithSingleElement executeCommand.ts / toHaveHTML.ts 🟠 Semantic Same code, different semantics depending on DOM element count at runtime
MaybeArray cast hiding array type toHaveElementProperty.ts 🟡 Type safety Compile-time lie; hides future regression risk

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

@greptileai, please verify the cases you mentioned in your previous comment; I've enumerated them below with answers!

Issue 1: forceFailure / isNot interaction — two distinct bugs
Bug A — silent pass on invalid input (single element branch)

This one should be fixed; please validate again

Bug B — full timeout delay before correctly failing (array length mismatch)

This one is acknowledged with a todo for later

Issue 2: OR-vs-AND semantic asymmetry (allowArrayWithSingleElement)

This is acknowledged with a todo to fix later with the asymmetric matcher oneOf or anyOf

Issue 3: MaybeArray cast hiding the array type in toHaveElementProperty

This one is fixed; validate again

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

I've verified all three items against the current code in executeCommand.ts, waitUntil.ts, toHaveElementProperty.ts, and elementsUtil.ts.


Bug A — Silent pass on invalid input (single element branch) ✅ Fixed

The fix hinges on how waitUntil works:

const result = isNot !== (await condition())  // loop breaks when true

Old behavior (presumably success: forceFailure ? false : compareResult.result):

  • isNot=true, forceFailure=truesuccess: false
  • waitUntil: true !== false = truebreaks immediately
  • Returns !isNot = false.not silently passes on invalid input 🐛

New behavior (success: forceFailure ? !!isNot : compareResult.result):

  • isNot=true, forceFailure=truesuccess: true
  • waitUntil: true !== true = falsenever breaks, waits until timeout
  • On timeout returns isNot = true as pass (raw) → .not correctly fails
  • isNot=false, forceFailure=truesuccess: false → times out → pass = false → fails ✅

The silent pass is gone. One trade-off to flag: with .not + array passed to a single-element matcher, it now waits the full timeout before correctly failing, rather than failing fast. This is the same class of issue as Bug B (acknowledged) and would be resolved by the future shouldAbort mechanism in the Future Considerations.


Issue 3 — MaybeArray cast hiding array type in toHaveElementProperty ✅ Fixed

The updated overload for elements now correctly exposes MaybeArray:

export async function toHaveElementProperty(
    received: WdioElementsMaybePromise,
    property: string,
    value: MaybeArray<string | number | RegExp | AsymmetricMatcher<string>>,
    options?: ExpectWebdriverIO.StringOptions
): Promise<AssertionResult>

And the implementation passes value directly to executeCommandWithStrategy as expectedValues (without casting it down to a scalar), so the strategy correctly applies index-based per-element comparison via Array.isArray(expectedValues) ? expectedValues[index] : expectedValues. The singleElementCompare callback receives a single resolved expectedValue per element — no array type is hidden. ✅


Summary:

Issue Status
Bug A — silent pass on invalid input ✅ Fixed (trade-off: isNot=true + invalid input waits full timeout, same class as Bug B)
Bug B — full timeout on array length mismatch ✅ Acknowledged, TODO for shouldAbort
Issue 2 — OR/AND asymmetry in allowArrayWithSingleElement ✅ Acknowledged, TODO for oneOf/anyOf
Issue 3 — MaybeArray cast in toHaveElementProperty ✅ Fixed

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

@greptile, what do you mean by the following? How to fix?

FnWhenElementOrArrayLike now accepts separate FnElement and FnArray type params for discriminated element vs. element-array overloads; each matcher gets dedicated $$() signatures; behaviorally correct but changes the conditional type shape which is technically a type-level breaking change.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Looking at the type definition in the PR:

Before (original, 2 params):

type FnWhenElementOrArrayLike<ActualT, Fn> = 
  ActualT extends ElementArrayLike ? Fn : 
  ActualT extends ElementLike ? Fn : never

After (this PR, 3 params):

type FnWhenElementOrArrayLike<ActualT, FnElement, FnArray = FnElement> = 
  ActualT extends ElementArrayLike ? FnArray : 
  ActualT extends ElementLike ? FnElement : never

The "type-level breaking change" is that the conditional type now resolves to two potentially distinct types (FnElement vs FnArray) rather than the same Fn in both branches. TypeScript's conditional type resolution and distributivity behave differently when the branches can diverge — so generic code that previously inferred or narrowed through FnWhenElementOrArrayLike might resolve differently even though runtime behavior is unchanged.

Is this actually a problem in practice? No, for two reasons:

  1. FnArray defaults to FnElement, so any existing code using FnWhenElementOrArrayLike<X, SomeFn> with 2 type args still compiles identically.
  2. FnWhenElementOrArrayLike is an internal utility type — users extend Matchers<R, T>, not this type directly.

If you want to be conservative, the cleanest fix is to leave the original 2-param type untouched and introduce a separate helper only used internally:

// Keep unchanged for backward compat:
type FnWhenElementOrArrayLike<ActualT, Fn> = 
  ActualT extends ElementArrayLike ? Fn : 
  ActualT extends ElementLike ? Fn : never

// New discriminated helper used internally by matchers that need different $() vs $$() signatures:
type FnWhenElementOrArrayLikeDiscriminated<ActualT, FnElement, FnArray = FnElement> = 
  ActualT extends ElementArrayLike ? FnArray : 
  ActualT extends ElementLike ? FnElement : never

Then matchers like toHaveAttribute, toHaveText, etc. that need different signatures for $() vs $$() use FnWhenElementOrArrayLikeDiscriminated, while the simpler matchers (toBeDisplayed, toExist, etc.) keep using FnWhenElementOrArrayLike.

That said, the current approach in the PR is fine — this is a theoretical concern, not a practical one. Adding a @internal JSDoc on FnWhenElementOrArrayLike would be sufficient to signal it's not part of the public API surface.

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

Looking at the type definition in the PR:

Before (original, 2 params):

type FnWhenElementOrArrayLike<ActualT, Fn> = 
  ActualT extends ElementArrayLike ? Fn : 
  ActualT extends ElementLike ? Fn : never

After (this PR, 3 params):

type FnWhenElementOrArrayLike<ActualT, FnElement, FnArray = FnElement> = 
  ActualT extends ElementArrayLike ? FnArray : 
  ActualT extends ElementLike ? FnElement : never

The "type-level breaking change" is that the conditional type now resolves to two potentially distinct types (FnElement vs FnArray) rather than the same Fn in both branches. TypeScript's conditional type resolution and distributivity behave differently when the branches can diverge — so generic code that previously inferred or narrowed through FnWhenElementOrArrayLike might resolve differently even though runtime behavior is unchanged.

Is this actually a problem in practice? No, for two reasons:

  1. FnArray defaults to FnElement, so any existing code using FnWhenElementOrArrayLike<X, SomeFn> with 2 type args still compiles identically.
  2. FnWhenElementOrArrayLike is an internal utility type — users extend Matchers<R, T>, not this type directly.

If you want to be conservative, the cleanest fix is to leave the original 2-param type untouched and introduce a separate helper only used internally:

// Keep unchanged for backward compat:
type FnWhenElementOrArrayLike<ActualT, Fn> = 
  ActualT extends ElementArrayLike ? Fn : 
  ActualT extends ElementLike ? Fn : never

// New discriminated helper used internally by matchers that need different $() vs $$() signatures:
type FnWhenElementOrArrayLikeDiscriminated<ActualT, FnElement, FnArray = FnElement> = 
  ActualT extends ElementArrayLike ? FnArray : 
  ActualT extends ElementLike ? FnElement : never

Then matchers like toHaveAttribute, toHaveText, etc. that need different signatures for $() vs $$() use FnWhenElementOrArrayLikeDiscriminated, while the simpler matchers (toBeDisplayed, toExist, etc.) keep using FnWhenElementOrArrayLike.

That said, the current approach in the PR is fine — this is a theoretical concern, not a practical one. Adding a @internal JSDoc on FnWhenElementOrArrayLike would be sufficient to signal it's not part of the public API surface.

@greptileai, It resolves to 2 different functions only if you pass 2 different ones; otherwise, it stays like before when passing one, so no breaking. And if you pass 2 FNs, it is because you want them different, so it is totally fine and backward-compatible! Keep a note and update the Greptile Summary comment!

@dprevost-LMI

Copy link
Copy Markdown
Contributor Author

@greptileai, please explain the following with examples and maybe a fix?

Files Needing Attention: types/expect-webdriverio.d.ts — the FnWhenElementOrArrayLike widening is the only design-level question; consider the conservative two-type approach if API-extractor or declaration rollup tooling is part of the release pipeline.

@greptile-apps

greptile-apps Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Here's a concrete explanation of the FnWhenElementOrArrayLike widening concern.


The Type in Question

// types/expect-webdriverio.d.ts
type FnWhenElementOrArrayLike<ActualT, FnElement, FnArray = FnElement> =
    ActualT extends ElementArrayLike ? FnArray :
    ActualT extends ElementLike ? FnElement :
    never

This is a distributive conditional type — when ActualT is a union, TypeScript distributes the condition across each member.


The Widening Problem

Scenario 1 — Union ActualT collapses both overloads

// User code:
const el = Math.random() > 0.5
    ? await $('div')   // ElementLike
    : await $$('div')  // ElementArrayLike

// TypeScript infers: ActualT = ElementLike | ElementArrayLike
// Distributive expansion:
//   FnWhenElementOrArrayLike<ElementLike | ElementArrayLike, FnElement, FnArray>
//   = (ElementLike   extends ElementArrayLike ? FnArray : ElementLike   extends ElementLike ? FnElement : never)
//   | (ElementArrayLike extends ElementArrayLike ? FnArray : ...)
//   = FnElement | FnArray

// ❌ Both the single-element AND the array overloads are now callable:
await expect(el).toHaveText(['a', 'b'])  // accepted even if el is a single element

Scenario 2 — API-extractor / declaration rollup serializes it wrong

When @microsoft/api-extractor or a bundler like rollup-plugin-dts generates the output .d.ts, it inlines the conditional type. For generic class/interface members it often expands the type at the use-site — meaning the final published .d.ts can end up looking like:

// What ships in the npm package after rollup:
toHaveText: FnElement | FnArray  // widened — array overloads bleed onto $() element

instead of:

// What you intended:
toHaveText: (text: string | RegExp, options?: StringOptions) => Promise<void>  // for $()
toHaveText: (text: MaybeArray<string | RegExp>, options?: StringOptions) => Promise<void>  // for $$()

The Conservative Two-Type Fix

Split WdioElementOrArrayMatchers into two explicit interfaces and use a single conditional at the top level only to choose between them:

// Two concrete interfaces — no conditional types inside:
interface WdioElementMatchers {
    toHaveText(text: string | RegExp | ExpectWebdriverIO.PartialMatcher<string>, options?: ExpectWebdriverIO.StringOptions): Promise<void>
    /** soon deprecated to replace by oneOf() or anyOf() when available */
    toHaveText(text: Array<string | RegExp | ExpectWebdriverIO.PartialMatcher<string>>, options?: ExpectWebdriverIO.StringOptions): Promise<void>

    toHaveHTML(text: string | RegExp | ExpectWebdriverIO.PartialMatcher<string>, options?: ExpectWebdriverIO.HTMLOptions): Promise<void>
    toHaveHTML(text: Array<string | RegExp | ExpectWebdriverIO.PartialMatcher<string>>, options?: ExpectWebdriverIO.HTMLOptions): Promise<void>

    toBeDisplayed(options?: ExpectWebdriverIO.ToBeDisplayedOptions): Promise<void>
    // ... all other single-element matchers
}

interface WdioElementArrayMatchers {
    toHaveText(text: MaybeArray<string | RegExp | ExpectWebdriverIO.PartialMatcher<string>>, options?: ExpectWebdriverIO.StringOptions): Promise<void>
    toHaveHTML(text: MaybeArray<string | RegExp | ExpectWebdriverIO.PartialMatcher<string>>, options?: ExpectWebdriverIO.HTMLOptions): Promise<void>
    toBeDisplayed(options?: ExpectWebdriverIO.ToBeDisplayedOptions): Promise<void>
    // ... all other array matchers
}

// Single conditional at the interface boundary only:
type WdioElementOrArrayMatchers<_R, ActualT = unknown> =
    ActualT extends ElementArrayLike ? WdioElementArrayMatchers :
    ActualT extends ElementLike      ? WdioElementMatchers :
    {}

Why This Is Safer

Current (FnWhenElementOrArrayLike per-member) Conservative (two-interface)
Union ActualT Widens to FnElement | FnArray — both overloads callable Resolves to a clean single interface
API-extractor output May serialize each member as the widened union Serializes as the concrete interface name
IDE intellisense Shows both overloads for $() elements Shows only the correct overloads
Maintenance Each new matcher needs careful FnWhenElementOrArrayLike<> wrapping Add to one or both interfaces

If API-extractor is not part of your release pipeline (i.e., you ship the source .d.ts directly), the current approach is acceptable and simpler to maintain. The two-interface approach is only strictly necessary if the generated rollup .d.ts is what gets published to npm.

@dprevost-LMI
dprevost-LMI merged commit f982917 into webdriverio:main Jul 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants