Skip to content

fix: Refactoring and fixes prior to support array selector $$#2126

Draft
dprevost-LMI wants to merge 10 commits into
webdriverio:mainfrom
dprevost-LMI:prior-refactoring-for-$$-support
Draft

fix: Refactoring and fixes prior to support array selector $$#2126
dprevost-LMI wants to merge 10 commits into
webdriverio:mainfrom
dprevost-LMI:prior-refactoring-for-$$-support

Conversation

@dprevost-LMI

Copy link
Copy Markdown
Contributor

No description provided.

@dprevost-LMI dprevost-LMI marked this pull request as ready for review June 28, 2026 03:34
@dprevost-LMI dprevost-LMI marked this pull request as draft June 28, 2026 03:34
@greptile-apps

greptile-apps Bot commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR refactors and cleans up the matcher internals as a preparatory step toward supporting array selectors ($$). It extracts waitUntil into its own module, introduces richer element type guards (isElementArrayOrChainable, isElement, etc.), adds NumberMatcher/validateNumberOptions utilities, and refactors toHaveAttribute into properly overloaded signatures with an isStringOptions runtime dispatch.

  • Type-guard expansion (src/util/elementsUtil.ts): isElementArray is replaced by a family of guards that distinguish plain Element[], ElementArray, and ChainablePromiseArray, enabling future $$ array-selector support.
  • toHaveAttribute overload refactor (src/matchers/element/toHaveAttribute.ts): The function is split into toHaveAttributeAndValue / toHaveAttributeFn and wired together via isStringOptions, cleanly supporting (attr), (attr, opts), and (attr, value, opts) call signatures.
  • formatMessage fix (src/util/formatMessage.ts): numberError now uses isDefined() instead of truthiness checks, correctly handling 0 as a boundary value.

Confidence Score: 4/5

Safe to merge after addressing the null-guard removal in refetchElements.ts; all other changes are well-tested refactors.

The removal of elements.parent && before elements.foundWith in elements.parent in refetchElements.ts means that if a ChainablePromiseArray (a newly supported type) ever has parent set to null or undefined, the in operator will throw a TypeError instead of silently skipping the refetch. Since this PR explicitly adds support for ChainablePromiseArray, the removed guard is the one path most likely to encounter an uninitialised parent.

src/util/refetchElements.ts deserves a second look — the null guard for elements.parent was removed, and the replacement type guard does not provide the same protection.

Important Files Changed

Filename Overview
src/util/refetchElements.ts Removed elements.parent && null guard before elements.foundWith in elements.parent; isElementArrayOrChainable only checks property existence, not that the value is non-null, so a null parent would throw TypeError at runtime.
src/matchers/element/toHaveAttribute.ts Refactored into proper overloads with isStringOptions dispatch; deprecated overload is listed first, shadowing the new cleaner signatures in TypeScript resolution.
src/util/commandOptionsUtils.ts New isStringOptions type guard uses some to check allowed keys, accepting objects that mix valid option keys with unrelated properties.
src/util/waitUntil.ts Extracted waitUntil from src/utils.ts to its own module; logic is unchanged, now exported and well-tested.
src/util/numberOptionsUtil.ts Adds NumberMatcher class and validateNumberOptions helper; validateNumberOptions is used in toBeElementsArrayOfSize, while NumberMatcher/numberMatcherTester are preparatory work flagged in a previous thread.
src/util/elementsUtil.ts Replaces isElementArray with richer type guards: isElementArrayOrChainable, isStrictlyAwaitedElementArray, isElement, isElementArrayLike, isElementOrArrayLike, isElementOrNotEmptyElementArray.
src/util/formatMessage.ts Fixes numberError to use isDefined instead of truthiness checks, correctly handling 0 boundary values; adds array-of-elements branch to getSelectors.
src/matchers/element/toHaveElementClass.ts Renamed from toHaveClass.ts; old toHaveClass and toHaveClassContaining are preserved as deprecated wrappers.
src/matchers/element/toBeSelected.ts toBeChecked now delegates to toBeSelected via this.matcherName, eliminating the double-fire of beforeAssertion/afterAssertion that existed in the old code.
src/matchers/elements/toBeElementsArrayOfSize.ts Uses new validateNumberOptions helper; merges numberCommandOptions into commandOptions for proper wait/interval propagation.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["toHaveAttribute(el, attr, valueOrOptions?, opts?)"] --> B{isStringOptions\nvalueOrOptions?}
    B -- yes --> C["toHaveAttributeFn\n(presence check only)"]
    B -- no --> D["toHaveAttributeAndValue\n(value comparison)"]
    C --> E["waitUntil → conditionAttributeIsPresent\nel.getAttribute(attr) !== null"]
    D --> F["waitUntil → conditionAttributeValueMatchWithExpected\ncompareText(attr, expectedValue, opts)"]
    E --> G[enhanceError → AssertionResult]
    F --> G

    subgraph elementsUtil
        H[isElementArrayOrChainable] --> I{Array.isArray\n+ selector + parent + foundWith}
        J[isStrictlyAwaitedElementArray] --> K{isElementArrayOrChainable\n+ getElements}
        L[isElement] --> M{object + selector\n+ parent + getElement}
        N[isElementArrayLike] --> O{isElementArrayOrChainable\nOR every isElement}
    end

    subgraph refetchElements
        P["refetchElements(elements, wait)"] --> Q{elements &&\nwait > 0 &&\nisElementArrayOrChainable}
        Q -- yes --> R["elements.foundWith in elements.parent\n⚠️ parent not null-checked"]
        R --> S["browser.$$(selector) re-fetch"]
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["toHaveAttribute(el, attr, valueOrOptions?, opts?)"] --> B{isStringOptions\nvalueOrOptions?}
    B -- yes --> C["toHaveAttributeFn\n(presence check only)"]
    B -- no --> D["toHaveAttributeAndValue\n(value comparison)"]
    C --> E["waitUntil → conditionAttributeIsPresent\nel.getAttribute(attr) !== null"]
    D --> F["waitUntil → conditionAttributeValueMatchWithExpected\ncompareText(attr, expectedValue, opts)"]
    E --> G[enhanceError → AssertionResult]
    F --> G

    subgraph elementsUtil
        H[isElementArrayOrChainable] --> I{Array.isArray\n+ selector + parent + foundWith}
        J[isStrictlyAwaitedElementArray] --> K{isElementArrayOrChainable\n+ getElements}
        L[isElement] --> M{object + selector\n+ parent + getElement}
        N[isElementArrayLike] --> O{isElementArrayOrChainable\nOR every isElement}
    end

    subgraph refetchElements
        P["refetchElements(elements, wait)"] --> Q{elements &&\nwait > 0 &&\nisElementArrayOrChainable}
        Q -- yes --> R["elements.foundWith in elements.parent\n⚠️ parent not null-checked"]
        R --> S["browser.$$(selector) re-fetch"]
    end
Loading

Reviews (2): Last reviewed commit: "Fix parentheses precedence" | Re-trigger Greptile

Comment thread tsconfig.json
Comment on lines 39 to 75
export async function toHaveElementProperty(
received: WdioElementMaybePromise,
property: string,
value?: string | RegExp | AsymmetricMatcher<string> | null,
expected: string | number | null | RegExp | AsymmetricMatcher<string>,
options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
) {
const isNot = this.isNot
const { expectation = 'property', verb = 'have' } = this
const matcherName = 'toHaveElementProperty'

await options.beforeAssertion?.({
matcherName: 'toHaveElementProperty',
expectedValue: [property, value],
matcherName,
expectedValue: [property, expected],
options,
})

let el = await received?.getElement()
let prop: unknown
const pass = await waitUntil(
async () => {
const result = await executeCommand.call(this, el, condition, options, [property, value])
const result = await executeCommand.call(this, el, condition, options, [property, expected])
el = result.el as WebdriverIO.Element
prop = result.values

return result.success
},
isNot,
options
)

let message: string
if (value === undefined) {
if (expected === undefined) {
message = enhanceError(el, !isNot, pass, this, verb, expectation, property, options)
} else {
const expected = wrapExpectedWithArray(el, prop, value)
message = enhanceError(el, expected, prop, this, verb, expectation, property, options)
const wrappedExpected = wrapExpectedWithArray(el, prop, expected)
message = enhanceError(el, wrappedExpected, prop, this, verb, expectation, property, options)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Breaking API change: expected is now required, removing the property-existence check

The parameter was changed from optional value?: string | RegExp | AsymmetricMatcher<string> | null to required expected: string | number | null | RegExp | AsymmetricMatcher<string>. Any existing call of el.toHaveElementProperty('prop') (used to verify property existence) is now a TypeScript error. The implementation still contains if (expected === undefined) on line 70, which is now dead code since TypeScript prevents passing undefined. If callers must now pass null to check existence, that is a silent behavioral inversion: the old condition returned { result: true } when value was undefined, while the new condition returns { result: true } only when value === null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, to review later, we should allow an overloaded function to check the existing props and be able to pass command options without needing undefined
So something like

toHaveElementProperty('myPropName', {wait: 1})

It was achieved with toHaveAttribute so it's doable too here

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. toHaveAttribute is the perfect pattern to follow here. The key is splitting into two separate internal functions and dispatching based on whether expected was provided. Here's what the refactor should look like for toHaveElementProperty:

// Existence-only check (no value)
async function conditionProperty(el: WebdriverIO.Element, property: string) {
    const prop = await el.getProperty(property)
    if (prop === null || prop === undefined) {
        return { result: false, value: prop }
    }
    return { result: true, value: prop }
}

// Value comparison check
async function conditionPropertyAndValue(
    el: WebdriverIO.Element,
    property: string,
    value: string | number | RegExp | AsymmetricMatcher<string>,
    options: ExpectWebdriverIO.StringOptions
) {
    // existing condition logic, minus the value === null branch
    ...
}

export async function toHaveElementProperty(
    received: WdioElementMaybePromise,
    property: string,
    expected?: string | number | null | RegExp | AsymmetricMatcher<string>,
    options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
) {
    // ...beforeAssertion...

    const result = expected !== undefined
        ? await toHaveElementPropertyAndValue.call(this, received, property, expected, options)
        : await toHaveElementPropertyFn.call(this, received, property)

    // ...afterAssertion...
    return result
}

This mirrors toHaveAttribute exactly and means toHaveElementProperty('myPropName', { wait: 1 }) won't naturally work unless you add explicit overload signatures:

export function toHaveElementProperty(received: WdioElementMaybePromise, property: string, options?: ExpectWebdriverIO.StringOptions): Promise<...>
export function toHaveElementProperty(received: WdioElementMaybePromise, property: string, expected: string | number | null | RegExp | AsymmetricMatcher<string>, options?: ExpectWebdriverIO.StringOptions): Promise<...>

That way toHaveElementProperty('myPropName', { wait: 1 }) works naturally without needing to pass undefined. Worth filing this as a follow-up issue to track it separately from this PR.

Comment thread src/util/formatMessage.ts Outdated
Comment thread src/util/elementsUtil.ts
Comment thread types/expect-webdriverio.d.ts Outdated
if (typeof attributeValue !== 'string') {
return { result: false, value: attributeValue }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 toHaveAttributeAndValue exported without a default for options

The function is exported (used by toHaveHref, toHaveId, and toHaveClassContaining) but options has no default value. A direct call like toHaveAttributeAndValue(el, 'attr', 'val') will pass undefined as options, causing options.wait to throw at runtime. All current internal callers happen to pass options, but the exported surface is fragile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toHaveHref & toHaveId & toHaveClassContaining use default values in their signatures, so they pass default values, no?
Was the removal intended to ensure the defaults come from above, no?
Or maybe since we process only a subset, if we pass only stringOptions without commandOptions, the default still applies?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — my original concern was overstated. Looking at the actual callers:

  • toHaveHref: options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
  • toHaveId: options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
  • toHaveAttribute (the main entry point): options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS

All callers default options to DEFAULT_OPTIONS before passing it down, so toHaveAttributeAndValue always receives a fully-populated object — never undefined. The design is intentional: defaults are applied once at the top of the call chain, and inner functions are pure helpers that trust their callers.

My original comment was incorrect. Withdrawing it.

Comment on lines +19 to +92
export class NumberMatcher {
constructor(private options: ExpectWebdriverIO.NumberOptions = {}) {}

equals(actual: number | undefined): boolean {
if ( actual === undefined ) {
return false
}

if (isNumber(this.options.eq)) {
return actual === this.options.eq
}

if (isNumber(this.options.gte) && isNumber(this.options.lte)) {
return actual >= this.options.gte && actual <= this.options.lte
}

if (isNumber(this.options.gte)) {
return actual >= this.options.gte
}

if (isNumber(this.options.lte)) {
return actual <= this.options.lte
}

return false
}

toString(): string {
if (isNumber(this.options.eq)) {
return String(this.options.eq)
}

if (isDefined(this.options.gte) && isDefined(this.options.lte)) {
return `>= ${this.options.gte} && <= ${this.options.lte}`
}

if (isDefined(this.options.gte)) {
return `>= ${this.options.gte}`
}

if (isDefined(this.options.lte)) {
return `<= ${this.options.lte}`
}

return 'Incorrect number options provided'
}

toJSON(): string | number {
// Return the actual number for exact equality, so it serializes as 0 not "0"
if (isNumber(this.options.eq)) {
return this.options.eq
}
return this.toString()
}
}

/**
* Custom tester for number matchers to be used by the equal of expect during failure message generation
*/
export const numberMatcherTester = (a: unknown, b: unknown): boolean | undefined => {
const isNumberMatcherA = a instanceof NumberMatcher
const isNumberMatcherB = b instanceof NumberMatcher

if (isNumberMatcherA && isNumber(b)) {
return a.equals(b)
}

if (isNumberMatcherB && isNumber(a)) {
return b.equals(a)
}

// Return undefined to let other testers handle it
return undefined
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 NumberMatcher and numberMatcherTester are defined but not integrated

NumberMatcher and numberMatcherTester are tested in test/util/numberOptionsUtil.test.ts but no production file in this PR imports or calls them. They appear to be preparatory work for a future feature. Adding unused exports to a published package increases bundle size and creates dead surface area; consider gating them behind a separate commit once they are wired up.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO dpevost to review later

@dprevost-LMI dprevost-LMI marked this pull request as ready for review June 28, 2026 14:39
@dprevost-LMI dprevost-LMI marked this pull request as draft June 28, 2026 14:39
Comment thread src/util/refetchElements.ts
@dprevost-LMI dprevost-LMI force-pushed the prior-refactoring-for-$$-support branch from 95ebc65 to fcfeca9 Compare June 30, 2026 12:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant