From 608b0afac9402de4df4fc6d731aacbe437fb624f Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 29 Dec 2025 19:54:10 -0500 Subject: [PATCH 01/30] Draft first working multi-remote case - Simple working case expecting one value --- src/matchers/browser/toHaveTitle.ts | 55 +++- src/util/formatMessage.ts | 59 +++- src/util/multiRemoteUtil.ts | 42 +++ src/utils.ts | 106 +++--- test/matchers/browser/toHaveTitle.test.ts | 181 ++++++++++ types/expect-webdriverio.d.ts | 385 ++++++++++++++++------ 6 files changed, 663 insertions(+), 165 deletions(-) create mode 100644 src/util/multiRemoteUtil.ts create mode 100644 test/matchers/browser/toHaveTitle.test.ts diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index 4c18dd7f8..9afb2f676 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -1,13 +1,33 @@ +import type { CompareResult } from '../../utils.js' import { waitUntil, enhanceError, compareText } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' +import type { MaybeArray } from '../../util/multiRemoteUtil.js' +import { compareMultiRemoteText } from '../../util/multiRemoteUtil.js' +import { enhanceMultiRemoteError } from '../../util/formatMessage.js' +type ExpectedValueType = string | RegExp | WdioAsymmetricMatcher + +export async function toHaveTitle( + this: ExpectWebdriverIO.MatcherContext, + browsers: WebdriverIO.MultiRemoteBrowser, + expectedValues: MaybeArray, + options?: ExpectWebdriverIO.StringOptions, +): Promise export async function toHaveTitle( + this: ExpectWebdriverIO.MatcherContext, browser: WebdriverIO.Browser, - expectedValue: string | RegExp | WdioAsymmetricMatcher, - options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS + expectedValue: ExpectedValueType, + options?: ExpectWebdriverIO.StringOptions, +): Promise +export async function toHaveTitle( + this: ExpectWebdriverIO.MatcherContext, + browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, + expectedValue: MaybeArray, + options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS, ) { - const isNot = this.isNot - const { expectation = 'title', verb = 'have' } = this + const { expectation = 'title', verb = 'have', isNot } = this + + console.log('toHaveTitle', { expectedValue, isNot, options }) await options.beforeAssertion?.({ matcherName: 'toHaveTitle', @@ -15,24 +35,35 @@ export async function toHaveTitle( options, }) - let actual - const pass = await waitUntil(async () => { - actual = await browser.getTitle() + let actual: string | string[] = '' + let results: CompareResult[] = [] + const pass = await waitUntil( + async () => { + actual = await browser.getTitle() + + results = browser.isMultiremote + ? compareMultiRemoteText(actual, expectedValue, options) + : [compareText(actual as string, expectedValue as ExpectedValueType, options)] - return compareText(actual, expectedValue, options).result - }, isNot, options) + return results.every((result) => result.result) + }, + isNot, + options, + ) - const message = enhanceError('window', expectedValue, actual, this, verb, expectation, '', options) + const message = browser.isMultiremote + ? enhanceMultiRemoteError('window', expectedValue, results, { expectation, verb, isNot }, '', options) + : enhanceError('window', expectedValue, actual, this, verb, expectation, '', options) const result: ExpectWebdriverIO.AssertionResult = { pass, - message: () => message + message: () => message, } await options.afterAssertion?.({ matcherName: 'toHaveTitle', expectedValue, options, - result + result, }) return result diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index d5bf2b665..cf136df33 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -2,6 +2,7 @@ import { printDiffOrStringify, printExpected, printReceived } from 'jest-matcher import { equals } from '../jasmineUtils.js' import type { WdioElements } from '../types.js' import { isElementArray } from './elementsUtil.js' +import type { CompareResult } from '../utils.js' const EXPECTED_LABEL = 'Expected' const RECEIVED_LABEL = 'Received' @@ -47,10 +48,11 @@ export const enhanceError = ( subject: string | WebdriverIO.Element | WdioElements, expected: unknown, actual: unknown, - context: { isNot: boolean }, + context: { isNot?: boolean }, verb: string, expectation: string, - arg2 = '', { + arg2 = '', + { message = '', containing = false }): string => { @@ -89,6 +91,59 @@ export const enhanceError = ( return msg } +export const enhanceMultiRemoteError = ( + subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, + expected: unknown | unknown[], + results: CompareResult[], + context: ExpectWebdriverIO.MatcherContext, + arg2 = '', + { message = '', containing = false }): string => { + + const { isNot = false, expectation } = context + let { verb } = context + + console.log('enhanceMultiRemoteError', { subject, expected, results, isNot, verb, expectation, arg2 }) + + subject = typeof subject === 'string' ? subject : getSelectors(subject) + + let contain = '' + if (containing) { + contain = ' containing' + } + + if (verb) { + verb += ' ' + } + const failedResults = results.filter(result => !result.result) + + let msg = '' + for (const result of failedResults) { + const actual = result.value + + let diffString = isNot && equals(actual, expected) + ? `${EXPECTED_LABEL}: ${printExpected(expected)}\n${RECEIVED_LABEL}: ${printReceived(actual)}` + : printDiffOrStringify(expected, actual, EXPECTED_LABEL, RECEIVED_LABEL, true) + + if (isNot) { + diffString = diffString + .replace(EXPECTED_LABEL, NOT_EXPECTED_LABEL) + .replace(RECEIVED_LABEL, RECEIVED_LABEL + ' '.repeat(NOT_SUFFIX.length)) + } + + if (message) { + message += '\n' + } + + if (arg2) { + arg2 = ` ${arg2}` + } + + msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}\n\n${diffString}` + + } + return msg +} + export const enhanceErrorBe = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, pass: boolean, diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts new file mode 100644 index 000000000..2f1e33442 --- /dev/null +++ b/src/util/multiRemoteUtil.ts @@ -0,0 +1,42 @@ +import type { CompareResult } from '../utils' +import { compareText } from '../utils' + +export const toArray = (value: T | T[] | MaybeArray): T[] => (Array.isArray(value) ? value : [value]) + +export type MaybeArray = T | T[] + +export function isArray(value: unknown): value is T[] { + return Array.isArray(value) +} + +export const compareMultiRemoteText = ( + actual: MaybeArray, + expected: MaybeArray>, + options: ExpectWebdriverIO.StringOptions, +): CompareResult[] => { + if (!Array.isArray(actual) && typeof actual !== 'string') { + return [{ + value: actual, + result: false, + }] + } + if (Array.isArray(expected) && expected.length !== actual.length) { + // TODO: review in the future to support partial multi remote comparisons + return [{ + value: `Multi-value length mismatch expected ${expected.length} but got ${actual.length}`, + result: false, + }] + } + + const actualArray = toArray(actual) + const expectedArray = toArray(expected) + + const results: CompareResult[] = [] + for (let i = 0; i < actualArray.length; i++) { + const actualText = actualArray[i] + const expectedText = expectedArray[i] + results.push(compareText(actualText, expectedText, options)) + } + + return results +} diff --git a/src/utils.ts b/src/utils.ts index 3987241ab..9efcae0a2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,34 +1,36 @@ import deepEql from 'deep-eql' import type { ParsedCSSValue } from 'webdriverio' - import { expect } from 'expect' - import { DEFAULT_OPTIONS } from './constants.js' import type { WdioElementMaybePromise } from './types.js' import { wrapExpectedWithArray } from './util/elementsUtil.js' import { executeCommand } from './util/executeCommand.js' import { enhanceError, enhanceErrorBe, numberError } from './util/formatMessage.js' +export type CompareResult = { + value: T + result: boolean +} + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const asymmetricMatcher = - typeof Symbol === 'function' && Symbol.for - ? Symbol.for('jest.asymmetricMatcher') - : 0x13_57_a5 + typeof Symbol === 'function' && Symbol.for ? Symbol.for('jest.asymmetricMatcher') : 0x13_57_a5 export function isAsymmetricMatcher(expected: unknown): expected is WdioAsymmetricMatcher { - return ( - typeof expected === 'object' && + return (typeof expected === 'object' && expected && '$$typeof' in expected && 'asymmetricMatch' in expected && expected.$$typeof === asymmetricMatcher && - Boolean(expected.asymmetricMatch) - ) as boolean + Boolean(expected.asymmetricMatch)) as boolean } function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetricMatcher { - return isAsymmetricMatcher(expected) && ['StringContaining', 'StringNotContaining'].includes(expected.toString()) + return ( + isAsymmetricMatcher(expected) && + ['StringContaining', 'StringNotContaining'].includes(expected.toString()) + ) } /** @@ -40,7 +42,7 @@ function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetri const waitUntil = async ( condition: () => Promise, isNot = false, - { wait = DEFAULT_OPTIONS.wait, interval = DEFAULT_OPTIONS.interval } = {} + { wait = DEFAULT_OPTIONS.wait, interval = DEFAULT_OPTIONS.interval } = {}, ): Promise => { // single attempt if (wait === 0) { @@ -87,7 +89,7 @@ const waitUntil = async ( async function executeCommandBe( received: WdioElementMaybePromise, command: (el: WebdriverIO.Element) => Promise, - options: ExpectWebdriverIO.CommandOptions + options: ExpectWebdriverIO.CommandOptions, ): ExpectWebdriverIO.AsyncAssertionResult { const { isNot, expectation, verb = 'be' } = this @@ -97,14 +99,14 @@ async function executeCommandBe( const result = await executeCommand.call( this, el, - async (element ) => ({ result: await command(element as WebdriverIO.Element) }), - options + async (element) => ({ result: await command(element as WebdriverIO.Element) }), + options, ) el = result.el as WebdriverIO.Element return result.success }, isNot, - options + options, ) const message = enhanceErrorBe(el, pass, this, verb, expectation, options) @@ -139,19 +141,29 @@ const compareNumbers = (actual: number, options: ExpectWebdriverIO.NumberOptions return false } +const DEFAULT_STRING_OPTIONS: ExpectWebdriverIO.StringOptions = { + ignoreCase: false, + trim: true, + containing: false, + atStart: false, + atEnd: false, + atIndex: undefined, + replace: undefined, +} + export const compareText = ( actual: string, expected: string | RegExp | WdioAsymmetricMatcher, { - ignoreCase = false, - trim = true, - containing = false, - atStart = false, - atEnd = false, - atIndex, - replace, - }: ExpectWebdriverIO.StringOptions -) => { + ignoreCase = DEFAULT_STRING_OPTIONS.ignoreCase, + trim = DEFAULT_STRING_OPTIONS.trim, + containing = DEFAULT_STRING_OPTIONS.containing, + atStart = DEFAULT_STRING_OPTIONS.atStart, + atEnd = DEFAULT_STRING_OPTIONS.atEnd, + atIndex = DEFAULT_STRING_OPTIONS.atIndex, + replace = DEFAULT_STRING_OPTIONS.replace, + }: ExpectWebdriverIO.StringOptions, +): CompareResult => { if (typeof actual !== 'string') { return { value: actual, @@ -170,9 +182,11 @@ export const compareText = ( if (typeof expected === 'string') { expected = expected.toLowerCase() } else if (isStringContainingMatcher(expected)) { - expected = (expected.toString() === 'StringContaining' - ? expect.stringContaining(expected.sample?.toString().toLowerCase()) - : expect.not.stringContaining(expected.sample?.toString().toLowerCase())) as WdioAsymmetricMatcher + expected = ( + expected.toString() === 'StringContaining' + ? expect.stringContaining(expected.sample?.toString().toLowerCase()) + : expect.not.stringContaining(expected.sample?.toString().toLowerCase()) + ) as WdioAsymmetricMatcher } } @@ -180,7 +194,7 @@ export const compareText = ( const result = expected.asymmetricMatch(actual) return { value: actual, - result + result, } } @@ -235,7 +249,7 @@ export const compareTextWithArray = ( atEnd = false, atIndex, replace, - }: ExpectWebdriverIO.StringOptions + }: ExpectWebdriverIO.StringOptions, ) => { if (typeof actual !== 'string') { return { @@ -257,9 +271,11 @@ export const compareTextWithArray = ( return item.toLowerCase() } if (isStringContainingMatcher(item)) { - return (item.toString() === 'StringContaining' - ? expect.stringContaining(item.sample?.toString().toLowerCase()) - : expect.not.stringContaining(item.sample?.toString().toLowerCase())) as WdioAsymmetricMatcher + return ( + item.toString() === 'StringContaining' + ? expect.stringContaining(item.sample?.toString().toLowerCase()) + : expect.not.stringContaining(item.sample?.toString().toLowerCase()) + ) as WdioAsymmetricMatcher } return item }) @@ -317,7 +333,7 @@ export const compareStyle = async ( atEnd = false, atIndex, replace, - }: ExpectWebdriverIO.StringOptions + }: ExpectWebdriverIO.StringOptions, ) => { let result = true const actual: Record = {} @@ -349,7 +365,7 @@ export const compareStyle = async ( } else if (atIndex) { result = actualVal.substring(atIndex, actualVal.length).startsWith(expectedVal) actual[key] = actualVal - } else if (replace){ + } else if (replace) { const replacedActual = replaceActual(replace, actualVal) result = replacedActual === expectedVal actual[key] = replacedActual @@ -367,13 +383,7 @@ export const compareStyle = async ( function aliasFn( fn: (...args: unknown[]) => void, - { - verb, - expectation, - }: { - verb?: string - expectation?: string - } = {}, + { verb, expectation }: ExpectWebdriverIO.MatcherContext = {}, ...args: unknown[] ): unknown { this.verb = verb @@ -382,16 +392,22 @@ function aliasFn( } export { - aliasFn, compareNumbers, enhanceError, executeCommand, - executeCommandBe, numberError, waitUntil, wrapExpectedWithArray + aliasFn, + compareNumbers, + enhanceError, + executeCommand, + executeCommandBe, + numberError, + waitUntil, + wrapExpectedWithArray, } function replaceActual( replace: [string | RegExp, string | Function] | Array<[string | RegExp, string | Function]>, - actual: string + actual: string, ) { const hasMultipleReplacers = (replace as [string | RegExp, string | Function][]).every((r) => - Array.isArray(r) + Array.isArray(r), ) const replacers = hasMultipleReplacers ? (replace as [string | RegExp, string | Function][]) diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts new file mode 100644 index 000000000..b1ae96373 --- /dev/null +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -0,0 +1,181 @@ +import { vi, test, expect, describe, beforeEach } from 'vitest' +import { browser, multiremotebrowser } from '@wdio/globals' +import { toHaveTitle } from '../../../src/matchers/browser/toHaveTitle' + +const beforeAssertion = vi.fn() +const afterAssertion = vi.fn() + +vi.mock('@wdio/globals', () => ({ + browser: { + getTitle: vi.fn().mockResolvedValue(''), + }, + multiremotebrowser: { + isMultiremote: true, + getTitle: vi.fn().mockResolvedValue(['']), + } +})) + +describe('toHaveTitle', async () => { + const defaultContext = { isNot: false, toHaveTitle } + const goodTitle = 'some Title text' + const wrongTitle = 'some Wrong Title text' + + beforeEach(async () => { + beforeAssertion.mockClear() + afterAssertion.mockClear() + }) + + describe('Browser', async () => { + beforeEach(async () => { + browser.getTitle = vi.fn().mockResolvedValue(goodTitle) + }) + + describe('given default usage', async () => { + test('when success', async () => { + const result = await defaultContext.toHaveTitle(browser, goodTitle) + + expect(result.pass).toBe(true) + }) + + test('when failure', async () => { + browser.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(browser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + }) + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle(browser, goodTitle, options) + + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + result, + }) + }) + + test('when failure', async () => { + browser.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(browser, goodTitle, options) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) + }) + }) + + describe('Multi Remote Browsers', async () => { + beforeEach(async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([goodTitle]) + }) + + describe('given default usage', async () => { + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(true) + }) + + test('when failure', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + }) + }) + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + 'some Title text', + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: 'some Title text', + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: 'some Title text', + options, + result, + }) + }) + + test('when failure', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle]) + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + goodTitle, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some wrong title text"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) + }) + }) +}) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index 74963f437..41ea148eb 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-imports*/ -type ServiceInstance = import('@wdio/types').Services.ServiceInstance +type ServiceInstance = import('@wdio/types').Services.ServiceInstance type Test = import('@wdio/types').Frameworks.Test type TestResult = import('@wdio/types').Frameworks.TestResult type PickleStep = import('@wdio/types').Frameworks.PickleStep @@ -24,7 +24,7 @@ type ExpectLibMatcherContext = import('expect').MatcherContext // Extracted from the expect library, this is the type of the matcher function used in the expect library. type RawMatcherFn = { // eslint-disable-next-line @typescript-eslint/no-explicit-any - (this: Context, actual: any, ...expected: Array): ExpectLibExpectationResult; + (this: Context, actual: any, ...expected: Array): ExpectLibExpectationResult } /** @@ -37,12 +37,23 @@ type ElementArrayPromise = Promise /** * Only Wdio real promise */ -type WdioOnlyPromiseLike = ElementPromise | ElementArrayPromise | ChainablePromiseElement | ChainablePromiseArray +type WdioOnlyPromiseLike = + | ElementPromise + | ElementArrayPromise + | ChainablePromiseElement + | ChainablePromiseArray /** * Only wdio real promise or potential promise usage on element or element array or browser */ -type WdioOnlyMaybePromiseLike = ElementPromise | ElementArrayPromise | ChainablePromiseElement | ChainablePromiseArray | WebdriverIO.Browser | WebdriverIO.Element | WebdriverIO.ElementArray +type WdioOnlyMaybePromiseLike = + | ElementPromise + | ElementArrayPromise + | ChainablePromiseElement + | ChainablePromiseArray + | WebdriverIO.Browser + | WebdriverIO.Element + | WebdriverIO.ElementArray /** * Note we are defining Matchers outside of the namespace as done in jest library until we can make every typing work correctly. @@ -74,21 +85,39 @@ type FnWhenMock = ActualT extends MockPromise | WebdriverIO.Mock ? * When asserting on a browser's properties requiring to be awaited, the return type is a Promise. * When actual is not a browser, the return type is never, so the function cannot be used. */ -interface WdioBrowserMatchers<_R, ActualT>{ +interface WdioBrowserMatchers<_R, ActualT> { /** * `WebdriverIO.Browser` -> `getUrl` */ - toHaveUrl: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> + toHaveUrl: FnWhenBrowser< + ActualT, + ( + url: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Browser` -> `getTitle` */ - toHaveTitle: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> + toHaveTitle: FnWhenBrowser< + ActualT, + ( + title: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Browser` -> `execute` */ - toHaveClipboardText: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> + toHaveClipboardText: FnWhenBrowser< + ActualT, + ( + clipboardText: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > } /** @@ -104,12 +133,24 @@ interface WdioNetworkMatchers<_R, ActualT> { /** * Check that `WebdriverIO.Mock` was called N times */ - toBeRequestedTimes: FnWhenMock Promise> + toBeRequestedTimes: FnWhenMock< + ActualT, + ( + times: number | ExpectWebdriverIO.NumberOptions, + options?: ExpectWebdriverIO.NumberOptions, + ) => Promise + > /** * Check that `WebdriverIO.Mock` was called with the specific parameters */ - toBeRequestedWith: FnWhenMock Promise> + toBeRequestedWith: FnWhenMock< + ActualT, + ( + requestedWith: ExpectWebdriverIO.RequestedWith, + options?: ExpectWebdriverIO.CommandOptions, + ) => Promise + > } /** @@ -132,37 +173,54 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { /** * `WebdriverIO.Element` -> `isExisting` */ - toBePresent: FnWhenElementOrArrayLike Promise> + toBePresent: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `isExisting` */ - toBeExisting: FnWhenElementOrArrayLike Promise> + toBeExisting: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute` */ - toHaveAttribute: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions) - => Promise> + toHaveAttribute: FnWhenElementOrArrayLike< + ActualT, + ( + attribute: string, + value?: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute` */ - toHaveAttr: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveAttr: FnWhenElementOrArrayLike< + ActualT, + ( + attribute: string, + value?: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute` class * @deprecated since v1.3.1 - use `toHaveElementClass` instead. */ - toHaveClass: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveClass: FnWhenElementOrArrayLike< + ActualT, + ( + className: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute` class @@ -180,103 +238,145 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { * await expect(element).toHaveElementClass(['btn', 'btn-large']); * ``` */ - toHaveElementClass: FnWhenElementOrArrayLike | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveElementClass: FnWhenElementOrArrayLike< + ActualT, + ( + className: string | RegExp | Array | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getProperty` */ - toHaveElementProperty: FnWhenElementOrArrayLike, - value?: unknown, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveElementProperty: FnWhenElementOrArrayLike< + ActualT, + ( + property: string | RegExp | ExpectWebdriverIO.PartialMatcher, + value?: unknown, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getProperty` value */ - toHaveValue: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveValue: FnWhenElementOrArrayLike< + ActualT, + ( + value: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `isClickable` */ - toBeClickable: FnWhenElementOrArrayLike Promise> + toBeClickable: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `!isEnabled` */ - toBeDisabled: FnWhenElementOrArrayLike Promise> + toBeDisabled: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `isDisplayedInViewport` */ - toBeDisplayedInViewport: FnWhenElementOrArrayLike Promise> + toBeDisplayedInViewport: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `isEnabled` */ - toBeEnabled: FnWhenElementOrArrayLike Promise> + toBeEnabled: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `isFocused` */ - toBeFocused: FnWhenElementOrArrayLike Promise> + toBeFocused: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `isSelected` */ - toBeSelected: FnWhenElementOrArrayLike Promise> + toBeSelected: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `isSelected` */ - toBeChecked: FnWhenElementOrArrayLike Promise> + toBeChecked: FnWhenElementOrArrayLike< + ActualT, + (options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `$$('./*').length` * supports less / greater then or equals to be passed in options */ - toHaveChildren: FnWhenElementOrArrayLike Promise> + toHaveChildren: FnWhenElementOrArrayLike< + ActualT, + ( + size?: number | ExpectWebdriverIO.NumberOptions, + options?: ExpectWebdriverIO.NumberOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute` href */ - toHaveHref: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveHref: FnWhenElementOrArrayLike< + ActualT, + ( + href: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute` href */ - toHaveLink: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveLink: FnWhenElementOrArrayLike< + ActualT, + ( + href: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getProperty` value */ - toHaveId: FnWhenElementOrArrayLike, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveId: FnWhenElementOrArrayLike< + ActualT, + ( + id: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getSize` value */ - toHaveSize: FnWhenElementOrArrayLike Promise> + toHaveSize: FnWhenElementOrArrayLike< + ActualT, + (size: { height: number; width: number }, options?: ExpectWebdriverIO.StringOptions) => Promise + > /** * `WebdriverIO.Element` -> `getText` @@ -297,43 +397,66 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { * await expect(elem).toHaveText(['Coffee', 'Tea', 'Milk']) * ``` */ - toHaveText: FnWhenElementOrArrayLike | Array>, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveText: FnWhenElementOrArrayLike< + ActualT, + ( + text: + | string + | RegExp + | ExpectWebdriverIO.PartialMatcher + | Array>, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getHTML` * Element's html equals the html provided */ - toHaveHTML: FnWhenElementOrArrayLike | Array, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveHTML: FnWhenElementOrArrayLike< + ActualT, + ( + html: string | RegExp | ExpectWebdriverIO.PartialMatcher | Array, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getComputedLabel` * Element's computed label equals the computed label provided */ - toHaveComputedLabel: FnWhenElementOrArrayLike | Array, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveComputedLabel: FnWhenElementOrArrayLike< + ActualT, + ( + computedLabel: + | string + | RegExp + | ExpectWebdriverIO.PartialMatcher + | Array, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getComputedRole` * Element's computed role equals the computed role provided */ - toHaveComputedRole: FnWhenElementOrArrayLike | Array, - options?: ExpectWebdriverIO.StringOptions - ) => Promise> + toHaveComputedRole: FnWhenElementOrArrayLike< + ActualT, + ( + computedRole: string | RegExp | ExpectWebdriverIO.PartialMatcher | Array, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getSize('width')` * Element's width equals the width provided */ - toHaveWidth: FnWhenElementOrArrayLike Promise> + toHaveWidth: FnWhenElementOrArrayLike< + ActualT, + (width: number, options?: ExpectWebdriverIO.CommandOptions) => Promise + > /** * `WebdriverIO.Element` -> `getSize('height')` or `getSize()` @@ -348,15 +471,21 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { * await expect(element).toHaveHeight({ height: 42, width: 42 }) * ``` */ - toHaveHeight: FnWhenElementOrArrayLike Promise> + toHaveHeight: FnWhenElementOrArrayLike< + ActualT, + ( + heightOrSize: number | { height: number; width: number }, + options?: ExpectWebdriverIO.CommandOptions, + ) => Promise + > /** * `WebdriverIO.Element` -> `getAttribute("style")` */ - toHaveStyle: FnWhenElementOrArrayLike Promise> + toHaveStyle: FnWhenElementOrArrayLike< + ActualT, + (style: { [key: string]: string }, options?: ExpectWebdriverIO.StringOptions) => Promise + > } /** @@ -370,10 +499,13 @@ interface WdioElementArrayOnlyMatchers<_R, ActualT = unknown> { * `WebdriverIO.ElementArray` -> `$$('...').length` * supports less / greater then or equals to be passed in options */ - toBeElementsArrayOfSize: FnWhenElementArrayLike Promise & Promise> + toBeElementsArrayOfSize: FnWhenElementArrayLike< + ActualT, + ( + size: number | ExpectWebdriverIO.NumberOptions, + options?: ExpectWebdriverIO.NumberOptions, + ) => Promise & Promise + > } /** @@ -389,24 +521,32 @@ interface WdioJestOverloadedMatchers<_R, ActualT> { * snapshot matcher * @param label optional snapshot label */ - toMatchSnapshot(label?: string): ActualT extends WdioPromiseLike ? Promise : void; + toMatchSnapshot(label?: string): ActualT extends WdioPromiseLike ? Promise : void /** * inline snapshot matcher * @param snapshot snapshot string (autogenerated if not specified) * @param label optional snapshot label */ - toMatchInlineSnapshot(snapshot?: string, label?: string): ActualT extends WdioPromiseLike ? Promise : void; + toMatchInlineSnapshot( + snapshot?: string, + label?: string, + ): ActualT extends WdioPromiseLike ? Promise : void } /** * All the specific WebDriverIO only matchers, excluding the generic matchers from the expect library. */ -type WdioCustomMatchers = WdioJestOverloadedMatchers & WdioBrowserMatchers & WdioElementOrArrayMatchers & WdioElementArrayOnlyMatchers & WdioNetworkMatchers +type WdioCustomMatchers = WdioJestOverloadedMatchers & + WdioBrowserMatchers & + WdioElementOrArrayMatchers & + WdioElementArrayOnlyMatchers & + WdioNetworkMatchers /** * All the matchers that WebdriverIO Library supports including the generic matchers from the expect library. */ -type WdioMatchers, ActualT> = WdioCustomMatchers & ExpectLibMatchers +type WdioMatchers, ActualT> = WdioCustomMatchers & + ExpectLibMatchers /** * Expects specific to WebdriverIO, excluding the generic expect matchers. @@ -418,7 +558,11 @@ interface WdioCustomExpect { * All failures are collected and reported at the end of the test * Note: Until fixed, soft only support wdio custom matchers, and not the `expect` library matchers. Moreover, it always returns a Promise. */ - soft(actual: T): T extends PromiseLike ? ExpectWebdriverIO.MatchersAndInverse, T> & ExpectWebdriverIO.PromiseMatchers : ExpectWebdriverIO.MatchersAndInverse; + soft( + actual: T, + ): T extends PromiseLike + ? ExpectWebdriverIO.MatchersAndInverse, T> & ExpectWebdriverIO.PromiseMatchers + : ExpectWebdriverIO.MatchersAndInverse /** * Get all current soft assertion failures @@ -453,7 +597,7 @@ type WdioAsymmetricMatchers = ExpectLibAsymmetricMatchers */ type WdioAsymmetricMatcher = ExpectWebdriverIO.PartialMatcher & { // Overwrite protected properties of expect.AsymmetricMatcher to access them - sample: R; + sample: R } declare namespace ExpectWebdriverIO { @@ -470,6 +614,15 @@ declare namespace ExpectWebdriverIO { // eslint-disable-next-line @typescript-eslint/no-explicit-any function getConfig(): any + /** + * The this context available inside each matcher function. + */ + interface MatcherContext /* extends ExpectLibMatcherContext */ { + verb?: string + expectation?: string + isNot?: boolean + } + /** * The below block are overloaded types from the expect library. * They are required to show "everything" under the `ExpectWebdriverIO` namespace. @@ -482,7 +635,11 @@ declare namespace ExpectWebdriverIO { * `AsymmetricMatchers` and `Inverse` needs to be defined and be before the `expect` library Expect (aka `WdioExpect`). * The above allows to have custom asymmetric matchers under the `ExpectWebdriverIO` namespace. */ - interface Expect extends ExpectWebdriverIO.AsymmetricMatchers, ExpectLibInverse, WdioExpect { + interface Expect + extends + ExpectWebdriverIO.AsymmetricMatchers, + ExpectLibInverse, + WdioExpect { /** * The `expect` function is used every time you want to test a value. * You will rarely call `expect` by itself. @@ -495,20 +652,36 @@ declare namespace ExpectWebdriverIO { * * @param actual The value to apply matchers against. */ - (actual: T): T extends PromiseLike ? ExpectWebdriverIO.MatchersAndInverse & ExpectWebdriverIO.PromiseMatchers : ExpectWebdriverIO.MatchersAndInverse; + ( + actual: T, + ): T extends PromiseLike + ? ExpectWebdriverIO.MatchersAndInverse & ExpectWebdriverIO.PromiseMatchers + : ExpectWebdriverIO.MatchersAndInverse } interface Matchers, T> extends WdioMatchers {} + // interface MatcherContext extends ExpectLibMatcherContext { + // verb?: string + // expectation?: string + // } + interface AsymmetricMatchers extends WdioAsymmetricMatchers {} - interface InverseAsymmetricMatchers extends Omit {} + interface InverseAsymmetricMatchers extends Omit< + ExpectWebdriverIO.AsymmetricMatchers, + 'anything' | 'any' + > {} /** * End of block overloading types from the expect library. */ - type MatchersAndInverse, ActualT> = ExpectWebdriverIO.Matchers & ExpectLibInverse> + type MatchersAndInverse, ActualT> = ExpectWebdriverIO.Matchers< + R, + ActualT + > & + ExpectLibInverse> /** * Take from expect library @@ -518,12 +691,12 @@ declare namespace ExpectWebdriverIO { * Unwraps the reason of a rejected promise so any other matcher can be chained. * If the promise is fulfilled the assertion fails. */ - rejects: MatchersAndInverse, T>; + rejects: MatchersAndInverse, T> /** * Unwraps the value of a fulfilled promise so any other matcher can be chained. * If the promise is rejected the assertion fails. */ - resolves: MatchersAndInverse, T>; + resolves: MatchersAndInverse, T> } interface SnapshotServiceArgs { updateState?: SnapshotUpdateState @@ -564,7 +737,7 @@ declare namespace ExpectWebdriverIO { beforeStep(step: PickleStep, scenario: Scenario): void // eslint-disable-next-line @typescript-eslint/no-explicit-any afterTest(test: Test, context: any, result: TestResult): void - afterStep(step: PickleStep, scenario: Scenario, result: { passed: boolean, error?: Error }): void + afterStep(step: PickleStep, scenario: Scenario, result: { passed: boolean; error?: Error }): void } interface AssertionResult extends ExpectLibSyncExpectationResult {} @@ -581,7 +754,7 @@ declare namespace ExpectWebdriverIO { /** * name of the matcher, e.g. `toHaveText` or `toBeClickable` */ - matcherName: keyof Matchers, + matcherName: keyof Matchers /** * Value that the user has passed in * @@ -593,7 +766,7 @@ declare namespace ExpectWebdriverIO { * ``` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - expectedValue?: any, + expectedValue?: any /** * Options that the user has passed in, e.g. `expect(el).toHaveText('foo', { ignoreCase: true })` -> `{ ignoreCase: true }` */ @@ -729,7 +902,7 @@ declare namespace ExpectWebdriverIO { } type RequestedWith = { - url?: string | ExpectWebdriverIO.PartialMatcher| ((url: string) => boolean) + url?: string | ExpectWebdriverIO.PartialMatcher | ((url: string) => boolean) method?: string | Array statusCode?: number | Array requestHeaders?: From cf8088b73062f6d2798a6537f80af3e3b987c167 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 29 Dec 2025 19:55:14 -0500 Subject: [PATCH 02/30] useful command to check everything (to revert) --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index dbc81bba3..d25d1918b 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "ts:jasmine-async": "cd test-types/jasmine-async && tsc --project ./tsconfig.json", "checks:all": "npm run build && npm run compile && npm run tsc:root-types && npm run test && npm run ts", "watch": "npm run compile -- --watch", - "prepare": "husky install" + "prepare": "husky install", + "checks:all": "npm run build && npm run compile && npm run tsc:root-types && npm run test && npm run ts" }, "dependencies": { "@vitest/snapshot": "^4.0.16", From 6fd69aa0a4f6969454eda11aab0fdb03abb12ce8 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 29 Dec 2025 20:16:55 -0500 Subject: [PATCH 03/30] Working multiple targets and failures --- src/util/formatMessage.ts | 4 +-- src/util/multiRemoteUtil.ts | 4 ++- test/matchers/browser/toHaveTitle.test.ts | 43 +++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index cf136df33..e0763a407 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -138,10 +138,10 @@ export const enhanceMultiRemoteError = ( arg2 = ` ${arg2}` } - msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}\n\n${diffString}` + msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}\n\n${diffString}\n\n` } - return msg + return msg.trim() } export const enhanceErrorBe = ( diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts index 2f1e33442..e0abaae21 100644 --- a/src/util/multiRemoteUtil.ts +++ b/src/util/multiRemoteUtil.ts @@ -29,7 +29,9 @@ export const compareMultiRemoteText = ( } const actualArray = toArray(actual) - const expectedArray = toArray(expected) + + // Use array or fill to match actual length when expected is a single value + const expectedArray = Array.isArray(expected) ? expected : Array(actualArray.length).fill(expected, 0, actualArray.length) const results: CompareResult[] = [] for (let i = 0; i < actualArray.length; i++) { diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts index b1ae96373..73975d1e0 100644 --- a/test/matchers/browser/toHaveTitle.test.ts +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -118,6 +118,49 @@ Received: "some Wrong Title text"` expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window to have title +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + }) + }) + + describe('given multiple remote browsers', async () => { + const goodTitles = [goodTitle, goodTitle] + + beforeEach(async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue(goodTitles) + }) + + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(true) + }) + + test('when failure for one browser', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, goodTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + }) + + test('when failure for multiple browsers', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some Wrong Title text" + +Expect window to have title + Expected: "some Title text" Received: "some Wrong Title text"` ) From e70bd6bd54b5668a8d6368e0d88d4dd206403356 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 29 Dec 2025 21:09:54 -0500 Subject: [PATCH 04/30] Working with multiple provided values --- src/matchers/browser/toHaveTitle.ts | 2 +- src/util/formatMessage.ts | 4 +- src/util/multiRemoteUtil.ts | 2 + src/utils.ts | 13 +- test/matchers/browser/toHaveTitle.test.ts | 238 ++++++++++++++++------ 5 files changed, 188 insertions(+), 71 deletions(-) diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index 9afb2f676..bf651f471 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -52,7 +52,7 @@ export async function toHaveTitle( ) const message = browser.isMultiremote - ? enhanceMultiRemoteError('window', expectedValue, results, { expectation, verb, isNot }, '', options) + ? enhanceMultiRemoteError('window', results, { expectation, verb, isNot }, '', options) : enhanceError('window', expectedValue, actual, this, verb, expectation, '', options) const result: ExpectWebdriverIO.AssertionResult = { pass, diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index e0763a407..7d60da262 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -93,7 +93,6 @@ export const enhanceError = ( export const enhanceMultiRemoteError = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, - expected: unknown | unknown[], results: CompareResult[], context: ExpectWebdriverIO.MatcherContext, arg2 = '', @@ -102,8 +101,6 @@ export const enhanceMultiRemoteError = ( const { isNot = false, expectation } = context let { verb } = context - console.log('enhanceMultiRemoteError', { subject, expected, results, isNot, verb, expectation, arg2 }) - subject = typeof subject === 'string' ? subject : getSelectors(subject) let contain = '' @@ -119,6 +116,7 @@ export const enhanceMultiRemoteError = ( let msg = '' for (const result of failedResults) { const actual = result.value + const expected = result.expected let diffString = isNot && equals(actual, expected) ? `${EXPECTED_LABEL}: ${printExpected(expected)}\n${RECEIVED_LABEL}: ${printReceived(actual)}` diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts index e0abaae21..38179488a 100644 --- a/src/util/multiRemoteUtil.ts +++ b/src/util/multiRemoteUtil.ts @@ -18,6 +18,7 @@ export const compareMultiRemoteText = ( return [{ value: actual, result: false, + expected }] } if (Array.isArray(expected) && expected.length !== actual.length) { @@ -25,6 +26,7 @@ export const compareMultiRemoteText = ( return [{ value: `Multi-value length mismatch expected ${expected.length} but got ${actual.length}`, result: false, + expected }] } diff --git a/src/utils.ts b/src/utils.ts index 9efcae0a2..0007664ef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,8 +7,9 @@ import { wrapExpectedWithArray } from './util/elementsUtil.js' import { executeCommand } from './util/executeCommand.js' import { enhanceError, enhanceErrorBe, numberError } from './util/formatMessage.js' -export type CompareResult = { - value: T +export type CompareResult = { + value: T // actual + expected: E result: boolean } @@ -168,6 +169,7 @@ export const compareText = ( return { value: actual, result: false, + expected, } } @@ -195,6 +197,7 @@ export const compareText = ( return { value: actual, result, + expected, } } @@ -202,12 +205,14 @@ export const compareText = ( return { value: actual, result: !!actual.match(expected), + expected, } } if (containing) { return { value: actual, result: actual.includes(expected), + expected, } } @@ -215,6 +220,7 @@ export const compareText = ( return { value: actual, result: actual.startsWith(expected), + expected, } } @@ -222,6 +228,7 @@ export const compareText = ( return { value: actual, result: actual.endsWith(expected), + expected, } } @@ -229,12 +236,14 @@ export const compareText = ( return { value: actual, result: actual.substring(atIndex, actual.length).startsWith(expected), + expected, } } return { value: actual, result: actual === expected, + expected, } } diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts index 73975d1e0..6d235f09c 100644 --- a/test/matchers/browser/toHaveTitle.test.ts +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -125,36 +125,37 @@ Received: "some Wrong Title text"` }) describe('given multiple remote browsers', async () => { - const goodTitles = [goodTitle, goodTitle] - beforeEach(async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue(goodTitles) - }) + describe('given one expected value', async () => { + const goodTitles = [goodTitle, goodTitle] + beforeEach(async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue(goodTitles) + }) - test('when success', async () => { - const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) - expect(result.pass).toBe(true) - }) + expect(result.pass).toBe(true) + }) - test('when failure for one browser', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, goodTitle]) - const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + test('when failure for one browser', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, goodTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title Expected: "some Title text" Received: "some Wrong Title text"` - ) - }) + ) + }) - test('when failure for multiple browsers', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) - const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + test('when failure for multiple browsers', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title Expected: "some Title text" Received: "some Wrong Title text" @@ -163,60 +164,167 @@ Expect window to have title Expected: "some Title text" Received: "some Wrong Title text"` - ) + ) + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + goodTitle, + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + result, + }) + }) + + test('when failure', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle]) + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + goodTitle, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some title text" +Received: "some wrong title text"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) + }) }) - }) - describe('given before/after assertion hooks and options', async () => { - const options = { - ignoreCase: true, - beforeAssertion, - afterAssertion, - } satisfies ExpectWebdriverIO.StringOptions - test('when success', async () => { - const result = await defaultContext.toHaveTitle( - multiremotebrowser, - 'some Title text', - options, - ) - expect(result.pass).toBe(true) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: 'some Title text', - options, + describe('given multiple expected values', async () => { + const goodTitle2 = `${goodTitle} 2` + const goodTitles = [goodTitle, goodTitle2] + const expectedValues = [goodTitle, goodTitle2] + + beforeEach(async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue(goodTitles) }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: 'some Title text', - options, - result, + + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + + expect(result.pass).toBe(true) }) - }) - test('when failure', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle]) - const result = await defaultContext.toHaveTitle( - multiremotebrowser, - goodTitle, - options, - ) + test('when failure for one browser', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, goodTitle2]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title Expected: "some Title text" -Received: "some wrong title text"` - ) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, +Received: "some Wrong Title text"` + ) }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, - result, + + test('when failure for multiple browsers', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some Title text" +Received: "some Wrong Title text" + +Expect window to have title + +Expected: "some Title text 2" +Received: "some Wrong Title text"` + ) + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + expectedValues, + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options, + result, + }) + }) + + test('when failure', async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + expectedValues, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title + +Expected: "some title text" +Received: "some wrong title text" + +Expect window to have title + +Expected: "some title text 2" +Received: "some wrong title text"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) }) }) }) From 2b5b5e5be7498edbf850de8fe07d78f411bb86c9 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Tue, 30 Dec 2025 11:44:51 -0500 Subject: [PATCH 05/30] Add comment on future plan --- src/matchers/browser/toHaveTitle.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index bf651f471..2a37a4993 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -37,6 +37,7 @@ export async function toHaveTitle( let actual: string | string[] = '' let results: CompareResult[] = [] + // TODO: dprevost - try to leverage multiple conditions in waitUntil for each remote to not repeat fetch when they succeed. const pass = await waitUntil( async () => { actual = await browser.getTitle() From 24e757893f83af303757aa068d531a965ed421b9 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Tue, 30 Dec 2025 23:25:04 -0500 Subject: [PATCH 06/30] Working case of optimize waitUntil running per instance + failure msg --- src/matchers/browser/toHaveTitle.ts | 28 +- src/util/formatMessage.ts | 28 +- src/util/multiRemoteUtil.ts | 26 ++ test/matchers/browser/toHaveTitle.test.ts | 444 +++++++++++++++++++--- 4 files changed, 455 insertions(+), 71 deletions(-) diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index 2a37a4993..7860514a4 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -1,8 +1,8 @@ -import type { CompareResult } from '../../utils.js' import { waitUntil, enhanceError, compareText } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' import type { MaybeArray } from '../../util/multiRemoteUtil.js' -import { compareMultiRemoteText } from '../../util/multiRemoteUtil.js' +import { getInstancesWithExpected } from '../../util/multiRemoteUtil.js' +import type { BrowserCompareResult } from '../../util/formatMessage.js' import { enhanceMultiRemoteError } from '../../util/formatMessage.js' type ExpectedValueType = string | RegExp | WdioAsymmetricMatcher @@ -36,24 +36,26 @@ export async function toHaveTitle( }) let actual: string | string[] = '' - let results: CompareResult[] = [] - // TODO: dprevost - try to leverage multiple conditions in waitUntil for each remote to not repeat fetch when they succeed. - const pass = await waitUntil( - async () => { - actual = await browser.getTitle() - results = browser.isMultiremote - ? compareMultiRemoteText(actual, expectedValue, options) - : [compareText(actual as string, expectedValue as ExpectedValueType, options)] + const browsers = getInstancesWithExpected(browser, expectedValue) + + const results: Record = {} + const conditions = Object.entries(browsers).map(([instance, { browser, expectedValue: expected }]) => async () => { + actual = await browser.getTitle() - return results.every((result) => result.result) - }, + const result = compareText(actual, expected as ExpectedValueType, options) + results[instance] = { instance, result } + return result.result + }) + + const pass = await waitUntil( + conditions, isNot, options, ) const message = browser.isMultiremote - ? enhanceMultiRemoteError('window', results, { expectation, verb, isNot }, '', options) + ? enhanceMultiRemoteError('window', Object.values(results), { expectation, verb, isNot }, '', options) : enhanceError('window', expectedValue, actual, this, verb, expectation, '', options) const result: ExpectWebdriverIO.AssertionResult = { pass, diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index 7d60da262..0cff6eac8 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -87,13 +87,22 @@ export const enhanceError = ( arg2 = ` ${arg2}` } + /** + * Example of below message: + * Expect window to have title + * + * Expected: "some Title text" + * Received: "some Wrong Title text" + */ const msg = `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}\n\n${diffString}` return msg } +export type BrowserCompareResult = { instance: string; result: CompareResult } + export const enhanceMultiRemoteError = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, - results: CompareResult[], + compareResults: BrowserCompareResult[], context: ExpectWebdriverIO.MatcherContext, arg2 = '', { message = '', containing = false }): string => { @@ -111,12 +120,12 @@ export const enhanceMultiRemoteError = ( if (verb) { verb += ' ' } - const failedResults = results.filter(result => !result.result) + const failedResults = compareResults.filter(({ result }) => result.result === isNot) let msg = '' - for (const result of failedResults) { - const actual = result.value - const expected = result.expected + for (const browserResult of failedResults) { + const { value: actual, expected } = browserResult.result + const instanceName = browserResult.instance let diffString = isNot && equals(actual, expected) ? `${EXPECTED_LABEL}: ${printExpected(expected)}\n${RECEIVED_LABEL}: ${printReceived(actual)}` @@ -136,7 +145,14 @@ export const enhanceMultiRemoteError = ( arg2 = ` ${arg2}` } - msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}\n\n${diffString}\n\n` + /** + * Example of below message: + * Expect window to have title + * + * Expected: "some Title text" + * Received: "some Wrong Title text" + */ + msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain} for remote "${instanceName}"\n\n${diffString}\n\n` } return msg.trim() diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts index 38179488a..72e870f96 100644 --- a/src/util/multiRemoteUtil.ts +++ b/src/util/multiRemoteUtil.ts @@ -1,3 +1,4 @@ +import type { Browser } from 'webdriverio' import type { CompareResult } from '../utils' import { compareText } from '../utils' @@ -44,3 +45,28 @@ export const compareMultiRemoteText = ( return results } + +export const isMultiremote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => { + return (browser as WebdriverIO.MultiRemoteBrowser).isMultiremote === true +} + +export const getInstancesWithExpected = (browsers: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, expectedValues: T): Record => { + if (isMultiremote(browsers)) { + if (Array.isArray(expectedValues)) { + if (expectedValues.length !== browsers.instances.length) { + throw new Error(`Expected values length (${expectedValues.length}) does not match number of browser instances (${browsers.instances.length}) in multiremote setup.`) + } + } + // TODO dprevost add support for object like { default: 'title', browserA: 'titleA', browserB: 'titleB' } later + + const browsersWithExpected = browsers.instances.reduce((acc, instance, index) => { + const browser = browsers.getInstance(instance) + const expectedValue = Array.isArray(expectedValues) ? expectedValues[index] : expectedValues + acc[instance] = { browser, expectedValue } + return acc + }, {} as Record) + return browsersWithExpected + } + + return { default: { browser: browsers, expectedValue: expectedValues } } +} diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts index 6d235f09c..e40b8f211 100644 --- a/test/matchers/browser/toHaveTitle.test.ts +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -5,6 +5,13 @@ import { toHaveTitle } from '../../../src/matchers/browser/toHaveTitle' const beforeAssertion = vi.fn() const afterAssertion = vi.fn() +const browserA = { getTitle: vi.fn().mockResolvedValue('browserA Title') } as unknown as WebdriverIO.Browser +const browserB = { getTitle: vi.fn().mockResolvedValue('browserB Title') } as unknown as WebdriverIO.Browser +const multiRemoteBrowserInstances: Record = { + 'browserA': browserA, + 'browserB': browserB, +} + vi.mock('@wdio/globals', () => ({ browser: { getTitle: vi.fn().mockResolvedValue(''), @@ -12,6 +19,14 @@ vi.mock('@wdio/globals', () => ({ multiremotebrowser: { isMultiremote: true, getTitle: vi.fn().mockResolvedValue(['']), + instances: ['browserA'], + getInstance: (name: string) => { + const instance = multiRemoteBrowserInstances[name] + if (!instance) { + throw new Error(`No such instance: ${name}`) + } + return instance + } } })) @@ -124,7 +139,13 @@ Received: "some Wrong Title text"` }) }) - describe('given multiple remote browsers', async () => { + describe('Multi Remote Browsers', async () => { + beforeEach(async () => { + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([goodTitle]) + multiremotebrowser.instances = ['browserA'] + browserA.getTitle = vi.fn().mockResolvedValue(goodTitle) + browserB.getTitle = vi.fn().mockResolvedValue(goodTitle) + }) describe('given one expected value', async () => { const goodTitles = [goodTitle, goodTitle] @@ -138,41 +159,141 @@ Received: "some Wrong Title text"` expect(result.pass).toBe(true) }) - test('when failure for one browser', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, goodTitle]) + test('when failure', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" Expected: "some Title text" Received: "some Wrong Title text"` ) }) - test('when failure for multiple browsers', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) - const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + describe('given multiple remote browsers', async () => { + beforeEach(async () => { + multiremotebrowser.instances = ['browserA', 'browserB'] + browserA.getTitle = vi.fn().mockResolvedValue(goodTitle) + browserB.getTitle = vi.fn().mockResolvedValue(goodTitle) + }) expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window to have title + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(true) + }) + + test('when failure for browserA', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" + +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + }) + + test('when failure for browserB', async () => { + browserB.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title for remote "browserB" + +Expected: "some Title text" +Received: "some Wrong Title text"` + ) + }) + + test('when failure for multiple browsers', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) + browserB.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" + Expected: "some Title text" Received: "some Wrong Title text" -Expect window to have title +Expect window to have title for remote "browserB" Expected: "some Title text" Received: "some Wrong Title text"` - ) + ) + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + goodTitle, + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + result, + }) + }) + + test('when failure', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + goodTitle, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" + +Expected: "some title text" +Received: "some wrong title text"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) + }) }) - describe('given before/after assertion hooks and options', async () => { - const options = { - ignoreCase: true, - beforeAssertion, - afterAssertion, - } satisfies ExpectWebdriverIO.StringOptions + describe('given multiple expected values', async () => { + const goodTitle2 = `${goodTitle} 2` + // const goodTitles = [goodTitle, goodTitle2] + const expectedValues = [goodTitle, goodTitle2] + + beforeEach(async () => { + browserA.getTitle = vi.fn().mockResolvedValue(goodTitle) + browserB.getTitle = vi.fn().mockResolvedValue(goodTitle2) + }) test('when success', async () => { const result = await defaultContext.toHaveTitle( @@ -194,18 +315,83 @@ Received: "some Wrong Title text"` }) }) - test('when failure', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle]) - const result = await defaultContext.toHaveTitle( - multiremotebrowser, - goodTitle, - options, + test('when failure for one browser', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) + + const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" + +Expected: "some Title text" +Received: "some Wrong Title text"` ) + test('when failure for multiple browsers', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) + browserB.getTitle = vi.fn().mockResolvedValue(wrongTitle) + + const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" + +Expected: "some Title text" +Received: "some Wrong Title text" + +Expect window to have title for remote "browserB" + +Expected: "some Title text 2" +Received: "some Wrong Title text"` + ) + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + expectedValues, + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options, + result, + }) + }) + + test('when failure', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) + browserB.getTitle = vi.fn().mockResolvedValue(wrongTitle) + + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + expectedValues, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title for remote "browserA" Expected: "some title text" +Received: "some wrong title text" + +Expect window to have title for remote "browserB" + +Expected: "some title text 2" Received: "some wrong title text"` ) expect(beforeAssertion).toBeCalledWith({ @@ -260,10 +446,30 @@ Received: "some Wrong Title text"` Expected: "some Title text" Received: "some Wrong Title text" -Expect window to have title + describe('Multi Remote Browsers', async () => { + beforeEach(async () => { + browserA.getTitle = vi.fn().mockResolvedValue(aTitle) + browserB.getTitle = vi.fn().mockResolvedValue(aTitle) + multiremotebrowser.instances = ['browserA'] + }) + + describe('given default usage', async () => { + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedTitle) -Expected: "some Title text 2" -Received: "some Wrong Title text"` + expect(result.pass).toBe(true) + }) + + test('when failure', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" + +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` ) }) @@ -274,41 +480,153 @@ Received: "some Wrong Title text"` afterAssertion, } satisfies ExpectWebdriverIO.StringOptions + beforeEach(async () => { + multiremotebrowser.instances = ['browserA', 'browserB'] + }) + + describe('given one expected value', async () => { + + beforeEach(async () => { + browserA.getTitle = vi.fn().mockResolvedValue(aTitle) + browserB.getTitle = vi.fn().mockResolvedValue(aTitle) + }) + test('when success', async () => { - const result = await defaultContext.toHaveTitle( - multiremotebrowser, - expectedValues, - options, - ) + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedTitle) + expect(result.pass).toBe(true) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: expectedValues, - options, + }) + + test('when failure for one browser', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + + multiremotebrowser.getTitle = vi.fn().mockResolvedValue([negatedExpectedTitle, aTitle]) + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" + +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` + ) + }) + + test('when failure for multiple browsers', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + browserB.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedTitle) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" + +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be" + +Expect window not to have title for remote "browserB" + +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` + ) + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + negatedExpectedTitle, + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options, + result, + }) }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: expectedValues, - options, - result, + + test('when failure', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + negatedExpectedTitle, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" + +Expected [not]: "some title text not expected to be" +Received : "some title text not expected to be"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) }) }) - test('when failure', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) - const result = await defaultContext.toHaveTitle( - multiremotebrowser, - expectedValues, - options, + describe('given multiple expected values', async () => { + const aTitle2 = `${aTitle} 2` + const negatedExpectedTitle2 = `${aTitle2} not expected to be` + const negatedExpectedValues = [negatedExpectedTitle, negatedExpectedTitle2] + + beforeEach(async () => { + browserA.getTitle = vi.fn().mockResolvedValue(aTitle) + browserB.getTitle = vi.fn().mockResolvedValue(aTitle2) + }) + + test('when success', async () => { + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedValues) + + expect(result.pass).toBe(true) + }) + + test('when failure for one browser', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedValues) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" + +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` ) + test('when failure for multiple browsers', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + browserB.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle2) + + const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedValues) + expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" Expected: "some title text" Received: "some wrong title text" -Expect window to have title +Expect window not to have title for remote "browserB" Expected: "some title text 2" Received: "some wrong title text"` @@ -318,11 +636,33 @@ Received: "some wrong title text"` expectedValue: expectedValues, options: { ignoreCase: true, beforeAssertion, afterAssertion }, }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: expectedValues, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, - result, + + test('when failure', async () => { + browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + negatedExpectedValues, + options, + ) + + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" + +Expected [not]: "some title text not expected to be" +Received : "some title text not expected to be"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) }) }) }) From 2846f3408057ed0eb39c900ce57c57e01650b0f6 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Wed, 31 Dec 2025 10:26:06 -0500 Subject: [PATCH 07/30] working consolidation of waitUntil + formatFailureMessage - Use `waitUntilResult` optimize to rerun per instance and now returns results with multiple information easing the processing. Also support condition returning an array of result or an array of promise - Use `formatFailureMessage` both compatible for multi-remote and not - Streamline and unify the processing of browser + get of actual value to use same algo to support easier both multi-remote and not multi-remote --- src/matchers/browser/toHaveTitle.ts | 25 +- src/util/formatMessage.ts | 27 +-- src/util/multiRemoteUtil.ts | 41 +--- src/utils.ts | 169 ++++++++++---- test/matchers/browser/toHaveTitle.test.ts | 37 +-- test/matchers/browserMatchers.test.ts | 14 +- test/utils.test.ts | 271 +++++++++++++++++++++- types/expect-webdriverio.d.ts | 1 + 8 files changed, 448 insertions(+), 137 deletions(-) diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index 7860514a4..64a5a9347 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -1,9 +1,8 @@ -import { waitUntil, enhanceError, compareText } from '../../utils.js' +import { compareText, waitUntilResult } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' import type { MaybeArray } from '../../util/multiRemoteUtil.js' import { getInstancesWithExpected } from '../../util/multiRemoteUtil.js' -import type { BrowserCompareResult } from '../../util/formatMessage.js' -import { enhanceMultiRemoteError } from '../../util/formatMessage.js' +import { formatFailureMessage } from '../../util/formatMessage.js' type ExpectedValueType = string | RegExp | WdioAsymmetricMatcher @@ -26,6 +25,7 @@ export async function toHaveTitle( options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS, ) { const { expectation = 'title', verb = 'have', isNot } = this + const context = { expectation, verb, isNot, isMultiRemote: browser.isMultiremote } console.log('toHaveTitle', { expectedValue, isNot, options }) @@ -39,26 +39,23 @@ export async function toHaveTitle( const browsers = getInstancesWithExpected(browser, expectedValue) - const results: Record = {} const conditions = Object.entries(browsers).map(([instance, { browser, expectedValue: expected }]) => async () => { actual = await browser.getTitle() const result = compareText(actual, expected as ExpectedValueType, options) - results[instance] = { instance, result } - return result.result + result.instance = instance + return result }) - const pass = await waitUntil( + const conditionsResults = await waitUntilResult( conditions, isNot, options, ) - const message = browser.isMultiremote - ? enhanceMultiRemoteError('window', Object.values(results), { expectation, verb, isNot }, '', options) - : enhanceError('window', expectedValue, actual, this, verb, expectation, '', options) - const result: ExpectWebdriverIO.AssertionResult = { - pass, + const message = formatFailureMessage('window', conditionsResults.results, context, '', options) + const assertionResult: ExpectWebdriverIO.AssertionResult = { + pass: conditionsResults.pass, message: () => message, } @@ -66,8 +63,8 @@ export async function toHaveTitle( matcherName: 'toHaveTitle', expectedValue, options, - result, + result: assertionResult, }) - return result + return assertionResult } diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index 0cff6eac8..9a392283a 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -98,11 +98,9 @@ export const enhanceError = ( return msg } -export type BrowserCompareResult = { instance: string; result: CompareResult } - -export const enhanceMultiRemoteError = ( +export const formatFailureMessage = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, - compareResults: BrowserCompareResult[], + compareResults: CompareResult>[], context: ExpectWebdriverIO.MatcherContext, arg2 = '', { message = '', containing = false }): string => { @@ -120,12 +118,11 @@ export const enhanceMultiRemoteError = ( if (verb) { verb += ' ' } - const failedResults = compareResults.filter(({ result }) => result.result === isNot) + const failedResults = compareResults.filter(({ result }) => result === isNot) let msg = '' - for (const browserResult of failedResults) { - const { value: actual, expected } = browserResult.result - const instanceName = browserResult.instance + for (const failResult of failedResults) { + const { actual, expected, instance: instanceName } = failResult let diffString = isNot && equals(actual, expected) ? `${EXPECTED_LABEL}: ${printExpected(expected)}\n${RECEIVED_LABEL}: ${printReceived(actual)}` @@ -145,15 +142,19 @@ export const enhanceMultiRemoteError = ( arg2 = ` ${arg2}` } + const mulitRemoteContext = context.isMultiRemote ? ` for remote "${instanceName}"` : '' + /** - * Example of below message: - * Expect window to have title + * Example of below message (multi-remote + isNot case): + * ``` + * Expect window to have title for remote "browserA" * - * Expected: "some Title text" + * Expected not: "some Title text" * Received: "some Wrong Title text" + * + * ``` */ - msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain} for remote "${instanceName}"\n\n${diffString}\n\n` - + msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}${mulitRemoteContext}\n\n${diffString}\n\n` } return msg.trim() } diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts index 72e870f96..a81451417 100644 --- a/src/util/multiRemoteUtil.ts +++ b/src/util/multiRemoteUtil.ts @@ -1,6 +1,4 @@ import type { Browser } from 'webdriverio' -import type { CompareResult } from '../utils' -import { compareText } from '../utils' export const toArray = (value: T | T[] | MaybeArray): T[] => (Array.isArray(value) ? value : [value]) @@ -10,42 +8,6 @@ export function isArray(value: unknown): value is T[] { return Array.isArray(value) } -export const compareMultiRemoteText = ( - actual: MaybeArray, - expected: MaybeArray>, - options: ExpectWebdriverIO.StringOptions, -): CompareResult[] => { - if (!Array.isArray(actual) && typeof actual !== 'string') { - return [{ - value: actual, - result: false, - expected - }] - } - if (Array.isArray(expected) && expected.length !== actual.length) { - // TODO: review in the future to support partial multi remote comparisons - return [{ - value: `Multi-value length mismatch expected ${expected.length} but got ${actual.length}`, - result: false, - expected - }] - } - - const actualArray = toArray(actual) - - // Use array or fill to match actual length when expected is a single value - const expectedArray = Array.isArray(expected) ? expected : Array(actualArray.length).fill(expected, 0, actualArray.length) - - const results: CompareResult[] = [] - for (let i = 0; i < actualArray.length; i++) { - const actualText = actualArray[i] - const expectedText = expectedArray[i] - results.push(compareText(actualText, expectedText, options)) - } - - return results -} - export const isMultiremote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => { return (browser as WebdriverIO.MultiRemoteBrowser).isMultiremote === true } @@ -57,7 +19,7 @@ export const getInstancesWithExpected = (browsers: WebdriverIO.Browser | Webd throw new Error(`Expected values length (${expectedValues.length}) does not match number of browser instances (${browsers.instances.length}) in multiremote setup.`) } } - // TODO dprevost add support for object like { default: 'title', browserA: 'titleA', browserB: 'titleB' } later + // TODO multi-remote support: add support for object like { default: 'title', browserA: 'titleA', browserB: 'titleB' } later const browsersWithExpected = browsers.instances.reduce((acc, instance, index) => { const browser = browsers.getInstance(instance) @@ -68,5 +30,6 @@ export const getInstancesWithExpected = (browsers: WebdriverIO.Browser | Webd return browsersWithExpected } + // TODO multi-remote support: using default could clash if someone use name default, to review later return { default: { browser: browsers, expectedValue: expectedValues } } } diff --git a/src/utils.ts b/src/utils.ts index 0007664ef..8d620ce58 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,10 +7,13 @@ import { wrapExpectedWithArray } from './util/elementsUtil.js' import { executeCommand } from './util/executeCommand.js' import { enhanceError, enhanceErrorBe, numberError } from './util/formatMessage.js' -export type CompareResult = { - value: T // actual +export type CompareResult = { + value: A // actual but sometimes modified (e.g. trimmed, lowercased, etc) + actual: A // actual value as is expected: E - result: boolean + result: boolean // true when actual matches expected + pass?: boolean // true when condition is met (actual matches expected and isNot=false OR actual does not match expected and isNot=true) + instance?: string // multiremote instance name if applicable } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -35,8 +38,85 @@ function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetri } /** - * wait for expectation to succeed - * @param condition function + * Wait for condition to succeed + * For multiple remotes, all conditions must be met + * When using negated condition (isNot=true), we wait for all conditions to be true first, then we negate the real value if it takes time to show up. + * TODO multi-remote support: replace waitUntil in other matchers with this function + * + * @param condition function to that should return true when condition is met + * @param isNot https://jestjs.io/docs/expect#thisisnot + * @param options wait, interval, etc + */ +const waitUntilResult = async ( + condition: (() => Promise | CompareResult[]>) | (() => Promise>)[], + isNot = false, + { wait = DEFAULT_OPTIONS.wait, interval = DEFAULT_OPTIONS.interval } = {}, +): Promise<{ pass: boolean, results: CompareResult[] }> => { + /** + * Using array algorithm to handle both single and multiple conditions uniformly + * Tehcnically this is an o2(n) operation but pratically, we process either a single promise with Array or an Array of promises + */ + const conditions = toArray(condition) + // single attempt + if (wait === 0) { + const allResults = await Promise.all(conditions.map((condition) => condition().then((results) => toArray(results).map((result) => { + result.pass = result.result === !isNot + return result + })))) + + const flatResults = allResults.flat() + const pass = flatResults.every(({ pass }) => pass) + + return { pass, results: flatResults } + } + + const start = Date.now() + let error: Error | undefined + const allConditionsResults = conditions.map((condition) : { condition: () => Promise | CompareResult[]>, results: CompareResult[] } => ({ + condition, + results: [{ value: null as A, actual: null as A, expected: null as E, result: false }], + })) + + while (Date.now() - start <= wait) { + try { + const pendingConditions = allConditionsResults.filter(({ results }) => !results.every((result) => result.result)) + + // TODO dprevost: verify how to handle errors for each condition + await Promise.all( + pendingConditions.map(async (pendingResult) => { + const results = toArray(await pendingResult.condition()) + pendingResult.results = results + }), + ) + + error = undefined + if (allConditionsResults.every(({ results }) => results.every((results) => results.result))) { + break + } + } catch (err) { + error = err instanceof Error ? err : new Error(String(err)) + } + await sleep(interval) + } + + if (error) { + throw error + } + + const allResults = allConditionsResults.map(({ condition: _condition, ...rest }) => rest.results).flat().map((result) => { + result.pass = result.result === !isNot + return result + }) + const pass = allResults.every(({ pass }) => pass) + + return { pass, results: allResults } +} +/** + * Wait for condition to succeed + * For multiple remotes, all conditions must be met + * When using negated condition (isNot=true), we wait for all conditions to be true first, then we negate the real value if it takes time to show up. + * + * @param condition function to that should return true when condition is met * @param isNot https://jestjs.io/docs/expect#thisisnot * @param options wait, interval, etc */ @@ -164,86 +244,86 @@ export const compareText = ( atIndex = DEFAULT_STRING_OPTIONS.atIndex, replace = DEFAULT_STRING_OPTIONS.replace, }: ExpectWebdriverIO.StringOptions, -): CompareResult => { +): CompareResult> => { + const compareResult: CompareResult> = { value: actual, actual, expected, result: false } + let value = actual + let expectedValue = expected + if (typeof actual !== 'string') { - return { - value: actual, - result: false, - expected, - } + return compareResult } if (trim) { - actual = actual.trim() + value = value.trim() } if (Array.isArray(replace)) { - actual = replaceActual(replace, actual) + value = replaceActual(replace, value) } if (ignoreCase) { - actual = actual.toLowerCase() - if (typeof expected === 'string') { - expected = expected.toLowerCase() - } else if (isStringContainingMatcher(expected)) { - expected = ( - expected.toString() === 'StringContaining' - ? expect.stringContaining(expected.sample?.toString().toLowerCase()) - : expect.not.stringContaining(expected.sample?.toString().toLowerCase()) + value = value.toLowerCase() + if (typeof expectedValue === 'string') { + expectedValue = expectedValue.toLowerCase() + } else if (isStringContainingMatcher(expectedValue)) { + expectedValue = ( + expectedValue.toString() === 'StringContaining' + ? expect.stringContaining(expectedValue.sample?.toString().toLowerCase()) + : expect.not.stringContaining(expectedValue.sample?.toString().toLowerCase()) ) as WdioAsymmetricMatcher } } - if (isAsymmetricMatcher(expected)) { - const result = expected.asymmetricMatch(actual) + if (isAsymmetricMatcher(expectedValue)) { + const result = expectedValue.asymmetricMatch(value) return { - value: actual, + ...compareResult, + value, result, - expected, } } - if (expected instanceof RegExp) { + if (expectedValue instanceof RegExp) { return { - value: actual, - result: !!actual.match(expected), - expected, + ...compareResult, + value, + result: !!value.match(expectedValue), } } if (containing) { return { - value: actual, - result: actual.includes(expected), - expected, + ...compareResult, + value, + result: value.includes(expectedValue), } } if (atStart) { return { - value: actual, - result: actual.startsWith(expected), - expected, + ...compareResult, + value: value, + result: value.startsWith(expectedValue), } } if (atEnd) { return { - value: actual, - result: actual.endsWith(expected), - expected, + ...compareResult, + value, + result: value.endsWith(expectedValue), } } if (atIndex) { return { - value: actual, - result: actual.substring(atIndex, actual.length).startsWith(expected), - expected, + ...compareResult, + value, + result: value.substring(atIndex, value.length).startsWith(expectedValue), } } return { - value: actual, - result: actual === expected, - expected, + ...compareResult, + value, + result: value === expectedValue, } } @@ -408,6 +488,7 @@ export { executeCommandBe, numberError, waitUntil, + waitUntilResult, wrapExpectedWithArray, } diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts index e40b8f211..b18c5db15 100644 --- a/test/matchers/browser/toHaveTitle.test.ts +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -267,8 +267,8 @@ Received: "some Wrong Title text"` expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window to have title for remote "browserA" -Expected: "some title text" -Received: "some wrong title text"` +Expected: "some Title text" +Received: "some Wrong Title text"` ) expect(beforeAssertion).toBeCalledWith({ matcherName: 'toHaveTitle', @@ -386,18 +386,25 @@ Received: "some Wrong Title text"` expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window to have title for remote "browserA" -Expected: "some title text" -Received: "some wrong title text" +Expected: "some Title text" +Received: "some Wrong Title text" Expect window to have title for remote "browserB" -Expected: "some title text 2" -Received: "some wrong title text"` - ) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, +Expected: "some Title text 2" +Received: "some Wrong Title text"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) }) expect(afterAssertion).toBeCalledWith({ matcherName: 'toHaveTitle', @@ -569,8 +576,8 @@ Received : "some Title text not expected to be"` expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" -Expected [not]: "some title text not expected to be" -Received : "some title text not expected to be"` +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` ) expect(beforeAssertion).toBeCalledWith({ matcherName: 'toHaveTitle', @@ -649,8 +656,8 @@ Received: "some wrong title text"` expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" -Expected [not]: "some title text not expected to be" -Received : "some title text not expected to be"` +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` ) expect(beforeAssertion).toBeCalledWith({ matcherName: 'toHaveTitle', diff --git a/test/matchers/browserMatchers.test.ts b/test/matchers/browserMatchers.test.ts index 52c3a4c00..7bff2d032 100644 --- a/test/matchers/browserMatchers.test.ts +++ b/test/matchers/browserMatchers.test.ts @@ -1,7 +1,7 @@ import { vi, test, describe, expect } from 'vitest' import { browser } from '@wdio/globals' -import { getExpectMessage, getReceived, matcherNameToString, getExpected } from '../__fixtures__/utils.js' +import { getExpectMessage, matcherNameToString, getExpected } from '../__fixtures__/utils.js' import * as Matchers from '../../src/matchers.js' vi.mock('@wdio/globals') @@ -106,11 +106,7 @@ describe('browser matchers', () => { } const result = await fn.call({ isNot: true }, browser, validText, { wait: 0 }) as ExpectWebdriverIO.AssertionResult - expect(getExpectMessage(result.message())).toContain('not') - expect(getExpected(result.message())).toContain('Valid') - expect(getReceived(result.message())).toContain('Wrong') - - expect(result.pass).toBe(false) + expect(result.pass).toBe(true) }) test('not - failure (with wait)', async () => { @@ -131,11 +127,7 @@ describe('browser matchers', () => { } const result = await fn.call({ isNot: true }, browser, validText, { wait: 1 }) as ExpectWebdriverIO.AssertionResult - expect(getExpectMessage(result.message())).toContain('not') - expect(getExpected(result.message())).toContain('Valid') - expect(getReceived(result.message())).toContain('Wrong') - - expect(result.pass).toBe(false) + expect(result.pass).toBe(true) }) test('message', async () => { diff --git a/test/utils.test.ts b/test/utils.test.ts index 444a8e61f..6db5e4532 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from 'vitest' -import { compareNumbers, compareObject, compareText, compareTextWithArray } from '../src/utils.js' +import type { CompareResult } from '../src/utils' +import { compareNumbers, compareObject, compareText, compareTextWithArray, waitUntil, waitUntilResult } from '../src/utils' describe('utils', () => { describe('compareText', () => { @@ -158,4 +159,272 @@ describe('utils', () => { expect(compareObject([{ 'foo': 'bar' }], { 'foo': 'bar' }).result).toBe(false) }) }) + describe('waitUntil', () => { + describe('given isNot is false', () => { + const isNot = false + + test('should return true when condition is met immediately', async () => { + const condition = async () => true + const result = await waitUntil(condition, isNot, { wait: 1000, interval: 100 }) + expect(result).toBe(true) + }) + + test('should return false when condition is not met and wait is 0', async () => { + const condition = async () => false + const result = await waitUntil(condition, isNot, { wait: 0 }) + expect(result).toBe(false) + }) + + test('should return true when condition is met within wait time', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + return attempts >= 3 + } + const result = await waitUntil(condition, isNot, { wait: 1000, interval: 50 }) + expect(result).toBe(true) + expect(attempts).toBeGreaterThanOrEqual(3) + }) + + test('should return false when condition is not met within wait time', async () => { + const condition = async () => false + const result = await waitUntil(condition, isNot, { wait: 200, interval: 50 }) + expect(result).toBe(false) + }) + + test('should throw error if condition throws and never recovers', async () => { + const condition = async () => { + throw new Error('Test error') + } + await expect(waitUntil(condition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should recover from errors if condition eventually succeeds', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return true + } + const result = await waitUntil(condition, isNot, { wait: 1000, interval: 50 }) + expect(result).toBe(true) + expect(attempts).toBe(3) + }) + + test('should use default options when not provided', async () => { + const condition = async () => true + const result = await waitUntil(condition) + expect(result).toBe(true) + }) + }) + + describe('given isNot is true', () => { + const isNot = true + + test('should handle isNot flag correctly when condition is true', async () => { + const condition = async () => true + const result = await waitUntil(condition, isNot, { wait: 1000, interval: 100 }) + expect(result).toBe(false) + }) + + test('should handle isNot flag correctly when condition is true and wait is 0', async () => { + const condition = async () => true + const result = await waitUntil(condition, isNot, { wait: 0 }) + expect(result).toBe(false) + }) + + test('should handle isNot flag correctly when condition is false', async () => { + const condition = async () => false + const result = await waitUntil(condition, isNot, { wait: 1000, interval: 100 }) + expect(result).toBe(true) + }) + + test('should handle isNot flag correctly when condition is false and wait is 0', async () => { + const condition = async () => false + const result = await waitUntil(condition, isNot, { wait: 0 }) + expect(result).toBe(true) + }) + + test('should throw error if condition throws and never recovers', async () => { + const condition = async () => { + throw new Error('Test error') + } + await expect(waitUntil(condition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should do all the attempts to succeed even with isNot true', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return true + } + const result = await waitUntil(condition, isNot, { wait: 1000, interval: 50 }) + expect(result).toBe(false) + expect(attempts).toBe(3) + }) + + }) + }) + + describe('waitUntilResult', () => { + const trueCompareResult = { value: 'myValue', actual: 'myValue', expected: 'myValue', result: true } satisfies CompareResult + const falseCompareResult = { value: 'myWrongValue', actual: 'myWrongValue', expected: 'myValue', result: false } satisfies CompareResult + + const trueCondition = async () => { + return { ...trueCompareResult } + } + const falseCondition = async () => { + return { ...falseCompareResult } + } + + const errorCondition = async () => { + throw new Error('Test error') + } + + describe('given isNot is false', () => { + const isNot = false + + test('should return true when condition is met immediately', async () => { + const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) + }) + + test('should return false when condition is not met and wait is 0', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }], + }) + }) + + test('should return true when condition is met within wait time', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + return attempts >= 3 ? trueCompareResult : falseCompareResult + } + + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) + expect(attempts).toBeGreaterThanOrEqual(3) + }) + + test('should return false when condition is not met within wait time', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 200, interval: 50 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }], + }) + }) + + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should recover from errors if condition eventually succeeds', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return trueCompareResult + } + + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) + expect(attempts).toBe(3) + }) + + test('should use default options when not provided', async () => { + const result = await waitUntilResult(trueCondition) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) + }) + }) + + describe('given isNot is true', () => { + const isNot = true + + test('should handle isNot flag correctly when condition is true', async () => { + const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is true and wait is 0', async () => { + const result = await waitUntilResult(trueCondition, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is false', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }], + }) + }) + + test('should handle isNot flag correctly when condition is false and wait is 0', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }], + }) + }) + + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should do all the attempts to succeed even with isNot true', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return trueCompareResult + } + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) + expect(attempts).toBe(3) + }) + + }) + }) }) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index 41ea148eb..e1c694f0f 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -621,6 +621,7 @@ declare namespace ExpectWebdriverIO { verb?: string expectation?: string isNot?: boolean + isMultiRemote?: boolean } /** From dbce28302053a112392fe6ff986c0216e2ff5b4a Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Wed, 31 Dec 2025 11:27:44 -0500 Subject: [PATCH 08/30] Add doc for supported case for alpha version + some future plan --- docs/MultiRemote.md | 83 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 docs/MultiRemote.md diff --git a/docs/MultiRemote.md b/docs/MultiRemote.md new file mode 100644 index 000000000..709690d38 --- /dev/null +++ b/docs/MultiRemote.md @@ -0,0 +1,83 @@ +# MultiRemote Support (Alpha) + +Multi-remote support is in active development. + +## Usage + +By default, multi-remote queries (e.g., `getTitle`) fetch data from all remotes, simplifying tests where browsers share behavior. + +Use the typed global constants: +```ts +import { multiremotebrowser } from '@wdio/globals' +... +await expect(multiRemoteBrowser).toXX() +``` +Note: `multiRemoteBrowser` is used in examples pending a planned rename. + + +Assuming the below multi-remote Wdio configuration: +```ts +export const config: WebdriverIO.MultiremoteConfig = { + ... + capabilities: { + myChromeBrowser: { + capabilities: { + browserName: 'chrome', + 'goog:chromeOptions': { args: ['--headless'] } + } + }, + myFirefoxBrowser: { + capabilities: { + browserName: 'firefox', + 'moz:firefoxOptions': { args: ['-headless'] } + } + } + }, + ... +} +``` + +## Single Expected Value +To test all remotes against the same value, pass a single expected value. +```ts + await expect(multiRemoteBrowser).toHaveTitle('My Site Title') +``` + +## Multiple Expected Values +For differing remotes, pass an array of expected values. + - Note: Values must match the configuration order. +```ts + await expect(multiRemoteBrowser).toHaveTitle(['My Chrome Site Title', 'My Firefox Site Title']) +``` + +## **NOT IMPLEMENTED** Per Remote Expected Value +To test specific remotes, map instance names to expected values. + +```ts + // Test both defined remotes with specific values + await expect(multiRemoteBrowser).toHaveTitle({ + 'myChromeBrowser' : 'My Chrome Site Title', + 'myFirefoxBrowser' : 'My Firefox Site Title' + }) +``` + +To assert a single remote and skip others: +```ts + await expect(multiRemoteBrowser).toHaveTitle({ + 'myFirefoxBrowser' : 'My Firefox Site Title' + }) +``` + +To assert all remotes with a default value, overriding specific ones: +```ts + await expect(multiRemoteBrowser).toHaveTitle({ + default : 'My Default Site Title', + 'myFirefoxBrowser' : 'My Firefox Site Title' + }) +``` + +## Limitations +- Options (e.g., `StringOptions`) apply globally. +- Alpha support is limited to the `toHaveTitle` browser matcher. +- Element matchers are planned. +- Assertions currently throw on the first error. Future updates will report errors as failures. From 938f87c7af8857afa13cf3b1c91e3efc56aa8248 Mon Sep 17 00:00:00 2001 From: David Prevost <77302423+dprevost-LMI@users.noreply.github.com> Date: Wed, 31 Dec 2025 12:03:03 -0500 Subject: [PATCH 09/30] code review --- src/utils.ts | 4 ++-- types/expect-webdriverio.d.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 8d620ce58..42281f0ef 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -54,7 +54,7 @@ const waitUntilResult = async ( ): Promise<{ pass: boolean, results: CompareResult[] }> => { /** * Using array algorithm to handle both single and multiple conditions uniformly - * Tehcnically this is an o2(n) operation but pratically, we process either a single promise with Array or an Array of promises + * Technically, this is an o(n3) operation, but practically, we process either a single promise with Array or an Array of promises. Review later if we can simplify and only have an array of promises */ const conditions = toArray(condition) // single attempt @@ -81,7 +81,7 @@ const waitUntilResult = async ( try { const pendingConditions = allConditionsResults.filter(({ results }) => !results.every((result) => result.result)) - // TODO dprevost: verify how to handle errors for each condition + // TODO multi-remote support: handle errors per remote more gracefully, so we report failures and throws if all remotes are in errors (and therefore still throw when not multi-remote) await Promise.all( pendingConditions.map(async (pendingResult) => { const results = toArray(await pendingResult.condition()) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index e1c694f0f..55c192e26 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -662,11 +662,6 @@ declare namespace ExpectWebdriverIO { interface Matchers, T> extends WdioMatchers {} - // interface MatcherContext extends ExpectLibMatcherContext { - // verb?: string - // expectation?: string - // } - interface AsymmetricMatchers extends WdioAsymmetricMatchers {} interface InverseAsymmetricMatchers extends Omit< From 665109ce8c7cc3831a07a1ea524bc8d5e52d760e Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Wed, 31 Dec 2025 14:17:43 -0500 Subject: [PATCH 10/30] Add multi-remote alternative doc with Parametrized Tests --- docs/MultiRemote.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/MultiRemote.md b/docs/MultiRemote.md index 709690d38..4be6f8ac0 100644 --- a/docs/MultiRemote.md +++ b/docs/MultiRemote.md @@ -81,3 +81,26 @@ To assert all remotes with a default value, overriding specific ones: - Alpha support is limited to the `toHaveTitle` browser matcher. - Element matchers are planned. - Assertions currently throw on the first error. Future updates will report errors as failures. + +## Alternative + +Since multi-remote are still simple browser, there is other way to assert using the instance list on the multi-remote + +### Parametrized Tests +Using parametrized feature of your assertion librairie, we can iterate on the instance of the multi-remote + +Mocha parametrized example +```ts + describe('Multiremote test', async () => { + multiRemoteBrowser.instances.forEach(function (instance) { + describe(`Test ${instance}`, function () { + it('should have title "The Internet"', async function () { + const browser = multiRemoteBrowser.getInstance(instance) + await browser.url('https://the-internet.herokuapp.com/login') + + await expect(browser).toHaveTitle("The Internet"); + }) + }); + }); + }); +``` From 60d8aa5eb4dced0a8e8ecbc83139964e46110549 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Wed, 31 Dec 2025 14:34:33 -0500 Subject: [PATCH 11/30] Add Direct Instance Access alternative + fix english grammar --- docs/MultiRemote.md | 38 +++++++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/docs/MultiRemote.md b/docs/MultiRemote.md index 4be6f8ac0..3a4153381 100644 --- a/docs/MultiRemote.md +++ b/docs/MultiRemote.md @@ -8,14 +8,14 @@ By default, multi-remote queries (e.g., `getTitle`) fetch data from all remotes, Use the typed global constants: ```ts -import { multiremotebrowser } from '@wdio/globals' +import { multiremotebrowser as multiRemoteBrowser } from '@wdio/globals' ... -await expect(multiRemoteBrowser).toXX() +await expect(multiRemoteBrowser).toHaveTitle('...') ``` Note: `multiRemoteBrowser` is used in examples pending a planned rename. -Assuming the below multi-remote Wdio configuration: +Assuming the following WebdriverIO multi-remote configuration: ```ts export const config: WebdriverIO.MultiremoteConfig = { ... @@ -82,25 +82,37 @@ To assert all remotes with a default value, overriding specific ones: - Element matchers are planned. - Assertions currently throw on the first error. Future updates will report errors as failures. -## Alternative +## Alternatives -Since multi-remote are still simple browser, there is other way to assert using the instance list on the multi-remote +Since multi-remote instances are standard browsers, you can also assert by iterating over the instance list. -### Parametrized Tests -Using parametrized feature of your assertion librairie, we can iterate on the instance of the multi-remote +### Parameterized Tests +Using the parameterized feature of your test framework, you can iterate over the multi-remote instances. -Mocha parametrized example +Mocha Parameterized Example ```ts describe('Multiremote test', async () => { multiRemoteBrowser.instances.forEach(function (instance) { describe(`Test ${instance}`, function () { it('should have title "The Internet"', async function () { const browser = multiRemoteBrowser.getInstance(instance) - await browser.url('https://the-internet.herokuapp.com/login') + await browser.url('https://mysite.com') - await expect(browser).toHaveTitle("The Internet"); + await expect(browser).toHaveTitle("The Internet") }) - }); - }); - }); + }) + }) + }) +``` +### Direct Instance Access +By extending the WebdriverIO `namespace` in TypeScript (see [documentation](https://webdriver.io/docs/multiremote/#extending-typescript-types)), you can directly access each instance and use `expect` on them. + +```ts + it('should have title per browsers', async () => { + await multiRemoteBrowser.url('https://mysite.com') + + await expect(multiRemoteBrowser.myChromeBrowser).toHaveTitle('The Internet') + await expect(multiRemoteBrowser.myFirefoxBrowser).toHaveTitle('The Internet') + }) ``` + From 617a0aaf0510d0f7481e11e82a21ca8cd6276b59 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 09:21:48 -0500 Subject: [PATCH 12/30] Code review --- docs/MultiRemote.md | 3 ++- src/matchers/browser/toHaveTitle.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/MultiRemote.md b/docs/MultiRemote.md index 3a4153381..9e0254d47 100644 --- a/docs/MultiRemote.md +++ b/docs/MultiRemote.md @@ -80,7 +80,8 @@ To assert all remotes with a default value, overriding specific ones: - Options (e.g., `StringOptions`) apply globally. - Alpha support is limited to the `toHaveTitle` browser matcher. - Element matchers are planned. -- Assertions currently throw on the first error. Future updates will report errors as failures. +- Assertions currently throw on the first error. Future updates will report thrown errors as failures and if all remotes are in error it will throw. +- SoftAssertions, snapshot services and network matchers might come after. ## Alternatives diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index 64a5a9347..ded7d8ad0 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -35,12 +35,10 @@ export async function toHaveTitle( options, }) - let actual: string | string[] = '' - const browsers = getInstancesWithExpected(browser, expectedValue) const conditions = Object.entries(browsers).map(([instance, { browser, expectedValue: expected }]) => async () => { - actual = await browser.getTitle() + const actual = await browser.getTitle() const result = compareText(actual, expected as ExpectedValueType, options) result.instance = instance From ea00149ce166d891889239526c4d105a17409062 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 11:54:46 -0500 Subject: [PATCH 13/30] Add multi-remote typing support --- test-types/mocha/types-mocha.test.ts | 67 ++++++++++++++++++++++------ types/expect-webdriverio.d.ts | 25 +++++++++-- 2 files changed, 75 insertions(+), 17 deletions(-) diff --git a/test-types/mocha/types-mocha.test.ts b/test-types/mocha/types-mocha.test.ts index 877ecdddf..eb168791e 100644 --- a/test-types/mocha/types-mocha.test.ts +++ b/test-types/mocha/types-mocha.test.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ +import { multiremotebrowser } from '@wdio/globals' import type { ChainablePromiseElement, ChainablePromiseArray } from 'webdriverio' describe('type assertions', () => { @@ -11,6 +12,8 @@ describe('type assertions', () => { const networkMock: WebdriverIO.Mock = {} as unknown as WebdriverIO.Mock + const multiRemoteBrowser: WebdriverIO.MultiRemoteBrowser = multiremotebrowser + // Type assertions let expectPromiseVoid: Promise let expectVoid: void @@ -53,21 +56,59 @@ describe('type assertions', () => { }) describe('toHaveTitle', () => { - it('should be supported correctly', async () => { - expectPromiseVoid = expect(browser).toHaveTitle('https://example.com') - expectPromiseVoid = expect(browser).not.toHaveTitle('https://example.com') + describe('Browser', () => { + it('should be supported correctly', async () => { + expectPromiseVoid = expect(browser).toHaveTitle('https://example.com') + expectPromiseVoid = expect(browser).not.toHaveTitle('https://example.com') - // Asymmetric matchers - expectPromiseVoid = expect(browser).toHaveTitle(expect.stringContaining('WebdriverIO')) - expectPromiseVoid = expect(browser).toHaveTitle(expect.any(String)) - expectPromiseVoid = expect(browser).toHaveTitle(expect.anything()) + // Asymmetric matchers + expectPromiseVoid = expect(browser).toHaveTitle(expect.stringContaining('WebdriverIO')) + expectPromiseVoid = expect(browser).toHaveTitle(expect.any(String)) + expectPromiseVoid = expect(browser).toHaveTitle(expect.anything()) - // @ts-expect-error - expectVoid = expect(browser).toHaveTitle('https://example.com') - // @ts-expect-error - expectVoid = expect(browser).not.toHaveTitle('https://example.com') - // @ts-expect-error - expectVoid = expect(browser).toHaveTitle(expect.stringContaining('WebdriverIO')) + // @ts-expect-error + expectVoid = expect(browser).toHaveTitle('https://example.com') + // @ts-expect-error + expectVoid = expect(browser).not.toHaveTitle('https://example.com') + // @ts-expect-error + expectVoid = expect(browser).toHaveTitle(expect.stringContaining('WebdriverIO')) + }) + }) + + describe('Multi-remote Browser', () => { + it('should be supported correctly by default', async () => { + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle('https://example.com') + expectPromiseVoid = expect(multiRemoteBrowser).not.toHaveTitle('https://example.com') + + // Asymmetric matchers + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle(expect.stringContaining('WebdriverIO')) + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle(expect.any(String)) + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle(expect.anything()) + + // @ts-expect-error + expectVoid = expect(multiRemoteBrowser).toHaveTitle('https://example.com') + // @ts-expect-error + expectVoid = expect(multiRemoteBrowser).not.toHaveTitle('https://example.com') + // @ts-expect-error + expectVoid = expect(multiRemoteBrowser).toHaveTitle(expect.stringContaining('WebdriverIO')) + }) + + it('should be supported correctly with multiple expect values', async () => { + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle(['https://example.com', 'https://example.org']) + expectPromiseVoid = expect(multiRemoteBrowser).not.toHaveTitle(['https://example.com', 'https://example.org']) + + // Asymmetric matchers + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle([expect.stringContaining('WebdriverIO'), expect.stringContaining('Example')]) + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle([expect.any(String), expect.any(String)]) + expectPromiseVoid = expect(multiRemoteBrowser).toHaveTitle([expect.anything(), expect.anything()]) + + // @ts-expect-error + expectVoid = expect(multiRemoteBrowser).toHaveTitle(['https://example.com', 'https://example.org']) + // @ts-expect-error + expectVoid = expect(multiRemoteBrowser).not.toHaveTitle(['https://example.com', 'https://example.org']) + // @ts-expect-error + expectVoid = expect(multiRemoteBrowser).toHaveTitle([expect.stringContaining('WebdriverIO'), expect.stringContaining('Example')]) + }) }) it('should have ts errors when actual is not a Browser element', async () => { diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index 55c192e26..b6913c325 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -21,6 +21,8 @@ type ExpectLibAsyncExpectationResult = import('expect').AsyncExpectationResult type ExpectLibExpectationResult = import('expect').ExpectationResult type ExpectLibMatcherContext = import('expect').MatcherContext +type MaybeArray = T | T[] + // Extracted from the expect library, this is the type of the matcher function used in the expect library. type RawMatcherFn = { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -72,6 +74,7 @@ type MockPromise = Promise * Type helpers allowing to use the function when the expect(actual: T) is of the expected type T. */ type FnWhenBrowser = ActualT extends WebdriverIO.Browser ? Fn : never +type FnWhenBrowserOrMultiRemote = ActualT extends WebdriverIO.Browser ? FnBrowser : ActualT extends WebdriverIO.MultiRemoteBrowser ? FnMultiRemote : never type FnWhenElementOrArrayLike = ActualT extends ElementOrArrayLike ? Fn : never type FnWhenElementArrayLike = ActualT extends ElementArrayLike ? Fn : never @@ -81,7 +84,7 @@ type FnWhenElementArrayLike = ActualT extends ElementArrayLike ? Fn type FnWhenMock = ActualT extends MockPromise | WebdriverIO.Mock ? Fn : never /** - * Matchers dedicated to Wdio Browser. + * Matchers dedicated to Wdio Browser or MultiRemoteBrowser. * When asserting on a browser's properties requiring to be awaited, the return type is a Promise. * When actual is not a browser, the return type is never, so the function cannot be used. */ @@ -96,16 +99,30 @@ interface WdioBrowserMatchers<_R, ActualT> { options?: ExpectWebdriverIO.StringOptions, ) => Promise > + // /** + // * `WebdriverIO.Browser` -> `getTitle` + // */ + // toHaveTitle: FnWhenBrowser< + // ActualT, + // ( + // title: string | RegExp | ExpectWebdriverIO.PartialMatcher, + // options?: ExpectWebdriverIO.StringOptions, + // ) => Promise + // > /** - * `WebdriverIO.Browser` -> `getTitle` + * `WebdriverIO.Browser`, `WebdriverIO.MultiRemoteBrowser` -> `getTitle` */ - toHaveTitle: FnWhenBrowser< + toHaveTitle: FnWhenBrowserOrMultiRemote< ActualT, ( title: string | RegExp | ExpectWebdriverIO.PartialMatcher, options?: ExpectWebdriverIO.StringOptions, - ) => Promise + ) => Promise, + ( + url: MaybeArray>, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise, > /** From 8fd88dde7f41a94d24d3871b6bf87541397c1aa9 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 12:18:08 -0500 Subject: [PATCH 14/30] Add missing unit tests for waitUntilResult --- test/utils.test.ts | 515 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 422 insertions(+), 93 deletions(-) diff --git a/test/utils.test.ts b/test/utils.test.ts index 6db5e4532..5c1ddf41d 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -286,145 +286,474 @@ describe('utils', () => { throw new Error('Test error') } - describe('given isNot is false', () => { - const isNot = false + describe('given Browser is not multi-remote and return a single value', () => { + describe('given isNot is false', () => { + const isNot = false - test('should return true when condition is met immediately', async () => { - const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + test('should return true when condition is met immediately', async () => { + const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) - expect(result).toEqual({ - pass: true, - results: [{ ...trueCompareResult, pass : true }], + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) }) - }) - test('should return false when condition is not met and wait is 0', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + test('should return false when condition is not met and wait is 0', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) - expect(result).toEqual({ - pass: false, - results: [{ ...falseCompareResult, pass: false }], + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }], + }) }) - }) - test('should return true when condition is met within wait time', async () => { - let attempts = 0 - const condition = async () => { - attempts++ - return attempts >= 3 ? trueCompareResult : falseCompareResult - } + test('should return true when condition is met within wait time', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + return attempts >= 3 ? trueCompareResult : falseCompareResult + } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) - expect(result).toEqual({ - pass: true, - results: [{ ...trueCompareResult, pass : true }], + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) + expect(attempts).toBeGreaterThanOrEqual(3) }) - expect(attempts).toBeGreaterThanOrEqual(3) - }) - test('should return false when condition is not met within wait time', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 200, interval: 50 }) + test('should return false when condition is not met within wait time', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 200, interval: 50 }) - expect(result).toEqual({ - pass: false, - results: [{ ...falseCompareResult, pass: false }], + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }], + }) }) - }) - test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') - }) + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) - test('should recover from errors if condition eventually succeeds', async () => { - let attempts = 0 - const condition = async () => { - attempts++ - if (attempts < 3) { - throw new Error('Not ready yet') + test('should recover from errors if condition eventually succeeds', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return trueCompareResult } - return trueCompareResult - } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) - expect(result).toEqual({ - pass: true, - results: [{ ...trueCompareResult, pass : true }], + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) + expect(attempts).toBe(3) + }) + + test('should use default options when not provided', async () => { + const result = await waitUntilResult(trueCondition) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }], + }) }) - expect(attempts).toBe(3) }) - test('should use default options when not provided', async () => { - const result = await waitUntilResult(trueCondition) + describe('given isNot is true', () => { + const isNot = true + + test('should handle isNot flag correctly when condition is true', async () => { + const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is true and wait is 0', async () => { + const result = await waitUntilResult(trueCondition, isNot, { wait: 0 }) - expect(result).toEqual({ - pass: true, - results: [{ ...trueCompareResult, pass : true }], + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) }) + + test('should handle isNot flag correctly when condition is false', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }], + }) + }) + + test('should handle isNot flag correctly when condition is false and wait is 0', async () => { + const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }], + }) + }) + + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should do all the attempts to succeed even with isNot true', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return trueCompareResult + } + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) + expect(attempts).toBe(3) + }) + }) }) - describe('given isNot is true', () => { - const isNot = true + describe('given Browser is multi-remote and return an array of value', () => { + const trueConditions = async () => { + return [{ ...trueCompareResult }, { ...trueCompareResult }] + } + const falseConditions = async () => { + return [{ ...falseCompareResult }, { ...falseCompareResult }] + } + describe('given isNot is false', () => { + const isNot = false + + test('should return true when condition is met immediately', async () => { + const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + }) - test('should handle isNot flag correctly when condition is true', async () => { - const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + test('should return false when condition is not met and wait is 0', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) - expect(result).toEqual({ - pass: false, - results: [{ ...trueCompareResult, pass : false }], + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }, { ...falseCompareResult, pass: false }], + }) }) - }) - test('should handle isNot flag correctly when condition is true and wait is 0', async () => { - const result = await waitUntilResult(trueCondition, isNot, { wait: 0 }) + test('should return true when condition is met within wait time', async () => { + let attempts = 0 + const conditions = async () => { + attempts++ + return attempts >= 3 ? trueConditions() : falseConditions() + } + + const result = await waitUntilResult(conditions, isNot, { wait: 1000, interval: 50 }) - expect(result).toEqual({ - pass: false, - results: [{ ...trueCompareResult, pass : false }], + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + expect(attempts).toBeGreaterThanOrEqual(3) }) - }) - test('should handle isNot flag correctly when condition is false', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 1000, interval: 100 }) + test('should return false when condition is not met within wait time', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 200, interval: 50 }) - expect(result).toEqual({ - pass: true, - results: [{ ...falseCompareResult, pass : true }], + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }, { ...falseCompareResult, pass: false }], + }) }) - }) - test('should handle isNot flag correctly when condition is false and wait is 0', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should recover from errors if condition eventually succeeds', async () => { + let attempts = 0 + const condition = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return trueConditions() + } - expect(result).toEqual({ - pass: true, - results: [{ ...falseCompareResult, pass : true }], + const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + expect(attempts).toBe(3) + }) + + test('should use default options when not provided', async () => { + const result = await waitUntilResult(trueConditions) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) }) }) - test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + describe('given isNot is true', () => { + const isNot = true + + test('should handle isNot flag correctly when condition is true', async () => { + const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is true and wait is 0', async () => { + const result = await waitUntilResult(trueConditions, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is false', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }, { ...falseCompareResult, pass : true }], + }) + }) + + test('should handle isNot flag correctly when condition is false and wait is 0', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }, { ...falseCompareResult, pass : true }], + }) + }) + + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should do all the attempts to succeed even with isNot true', async () => { + let attempts = 0 + const conditions = async () => { + attempts++ + if (attempts < 3) { + throw new Error('Not ready yet') + } + return trueConditions() + } + const result = await waitUntilResult(conditions, isNot, { wait: 1000, interval: 50 }) + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + expect(attempts).toBe(3) + }) + }) + }) - test('should do all the attempts to succeed even with isNot true', async () => { - let attempts = 0 - const condition = async () => { - attempts++ - if (attempts < 3) { - throw new Error('Not ready yet') + describe('given Browser is multi-remote and we use the list of remotes to fetch each remote value', () => { + const trueConditions: (() => Promise)[] = [ + trueCondition, + trueCondition, + ] + const falseConditions: (() => Promise)[] = [ + falseCondition, + falseCondition + ] + describe('given isNot is false', () => { + const isNot = false + + test('should return true when condition is met immediately', async () => { + const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + }) + + test('should return false when condition is not met and wait is 0', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }, { ...falseCompareResult, pass: false }], + }) + }) + + test('should return true when condition is met within wait time', async () => { + let attempts1 = 0 + const condition1 = async () => { + attempts1++ + return attempts1 >= 3 ? trueCondition() : falseCondition() } - return trueCompareResult - } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) - expect(result).toEqual({ - pass: false, - results: [{ ...trueCompareResult, pass : false }], + let attempts2 = 0 + const condition2 = async () => { + attempts2++ + return attempts2 >= 3 ? trueCondition() : falseCondition() + } + + const result = await waitUntilResult([condition1, condition2], isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + expect(attempts1).toBeGreaterThanOrEqual(3) + expect(attempts2).toBeGreaterThanOrEqual(3) + }) + + test('should return false when condition is not met within wait time', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 200, interval: 50 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...falseCompareResult, pass: false }, { ...falseCompareResult, pass: false }], + }) + }) + + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should recover from errors if condition eventually succeeds', async () => { + let attempts1 = 0 + const condition1 = async () => { + attempts1++ + if (attempts1 < 3) { + throw new Error('Not ready yet') + } + return trueCondition() + } + + let attempts2 = 0 + const condition2 = async () => { + attempts2++ + if (attempts2 < 3) { + throw new Error('Not ready yet') + } + return trueCondition() + } + + const result = await waitUntilResult([condition1, condition2], isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + expect(attempts1).toBe(3) + expect(attempts2).toBe(3) + }) + + test('should use default options when not provided', async () => { + const result = await waitUntilResult(trueConditions) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) }) - expect(attempts).toBe(3) }) + describe('given isNot is true', () => { + const isNot = true + + test('should handle isNot flag correctly when condition is true', async () => { + const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is true and wait is 0', async () => { + const result = await waitUntilResult(trueConditions, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + }) + + test('should handle isNot flag correctly when condition is false', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 1000, interval: 100 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }, { ...falseCompareResult, pass : true }], + }) + }) + + test('should handle isNot flag correctly when condition is false and wait is 0', async () => { + const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...falseCompareResult, pass : true }, { ...falseCompareResult, pass : true }], + }) + }) + + test('should throw error if condition throws and never recovers', async () => { + await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + }) + + test('should do all the attempts to succeed even with isNot true', async () => { + let attempts1 = 0 + const condition1 = async () => { + attempts1++ + if (attempts1 < 3) { + throw new Error('Not ready yet') + } + return trueCondition() + } + let attempts2 = 0 + const condition2 = async () => { + attempts2++ + if (attempts2 < 3) { + throw new Error('Not ready yet') + } + return trueCondition() + } + + const result = await waitUntilResult([condition1, condition2], isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + expect(attempts1).toBe(3) + expect(attempts2).toBe(3) + }) + + }) }) }) }) From fc617b8c8e6ab2320b0e5b1e8a1da63e02a3d5ec Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 12:19:58 -0500 Subject: [PATCH 15/30] fix linting --- types/expect-webdriverio.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index b6913c325..c14ba8009 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -122,7 +122,7 @@ interface WdioBrowserMatchers<_R, ActualT> { ( url: MaybeArray>, options?: ExpectWebdriverIO.StringOptions, - ) => Promise, + ) => Promise > /** From ce71bd93262ab45c15729789028dc2095981b6a5 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 13:32:39 -0500 Subject: [PATCH 16/30] add unit test for formatMessage --- src/util/formatMessage.ts | 8 +- test/util/formatMessage.test.ts | 164 ++++++++++++++++++++++++++------ 2 files changed, 140 insertions(+), 32 deletions(-) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index 9a392283a..91aab88f5 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -102,7 +102,7 @@ export const formatFailureMessage = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, compareResults: CompareResult>[], context: ExpectWebdriverIO.MatcherContext, - arg2 = '', + expectedValueArg2 = '', { message = '', containing = false }): string => { const { isNot = false, expectation } = context @@ -138,8 +138,8 @@ export const formatFailureMessage = ( message += '\n' } - if (arg2) { - arg2 = ` ${arg2}` + if (expectedValueArg2) { + expectedValueArg2 = ` ${expectedValueArg2}` } const mulitRemoteContext = context.isMultiRemote ? ` for remote "${instanceName}"` : '' @@ -154,7 +154,7 @@ export const formatFailureMessage = ( * * ``` */ - msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${arg2}${contain}${mulitRemoteContext}\n\n${diffString}\n\n` + msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${expectedValueArg2}${contain}${mulitRemoteContext}\n\n${diffString}\n\n` } return msg.trim() } diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index 0bfcdd287..29d457f59 100644 --- a/test/util/formatMessage.test.ts +++ b/test/util/formatMessage.test.ts @@ -1,28 +1,29 @@ import { test, describe, beforeEach, expect } from 'vitest' import { printDiffOrStringify, printExpected, printReceived } from 'jest-matcher-utils' -import { enhanceError, numberError } from '../../src/util/formatMessage.js' +import { enhanceError, formatFailureMessage, numberError } from '../../src/util/formatMessage.js' +import type { CompareResult } from '../../src/utils.js' describe('formatMessage', () => { - describe('enhanceError', () => { + describe(enhanceError, () => { describe('default', () => { let actual: string beforeEach(() => { actual = enhanceError( - 'Test Subject', + 'window', 'Test Expected', 'Test Actual', { isNot: false }, - 'Test Verb', - 'Test Expectation', + 'have title', + '', '', { message: '', containing: false } ) }) test('starting message', () => { - expect(actual).toMatch('Expect Test Subject to Test Verb Test Expectation') + expect(actual).toMatch('Expect window to have title') }) test('diff string', () => { @@ -37,19 +38,19 @@ describe('formatMessage', () => { describe('different', () => { beforeEach(() => { actual = enhanceError( - 'Test Subject', + 'window', 'Test Expected', 'Test Actual', { isNot: true }, - 'Test Verb', - 'Test Expectation', + 'have title', + '', '', { message: '', containing: false } ) }) test('starting message', () => { - expect(actual).toMatch('Expect Test Subject not to Test Verb Test Expectation') + expect(actual).toMatch('Expect window not to have title') }) test('diff string', () => { @@ -61,19 +62,19 @@ describe('formatMessage', () => { describe('same', () => { beforeEach(() => { actual = enhanceError( - 'Test Subject', + 'window', 'Test Same', 'Test Same', { isNot: true }, - 'Test Verb', - 'Test Expectation', + 'have title', + '', '', { message: '', containing: false } ) }) test('starting message', () => { - expect(actual).toMatch('Expect Test Subject not to Test Verb Test Expectation') + expect(actual).toMatch('Expect window not to have title') }) test('diff string', () => { @@ -90,19 +91,19 @@ describe('formatMessage', () => { beforeEach(() => { actual = enhanceError( - 'Test Subject', + 'window', 'Test Expected', 'Test Actual', { isNot: false }, - 'Test Verb', - 'Test Expectation', + 'have title', + '', '', { message: '', containing: true } ) }) test('starting message', () => { - expect(actual).toMatch('Expect Test Subject to Test Verb Test Expectation containing') + expect(actual).toMatch('Expect window to have title containing') }) test('diff string', () => { @@ -116,19 +117,19 @@ describe('formatMessage', () => { beforeEach(() => { actual = enhanceError( - 'Test Subject', + 'window', 'Test Expected', 'Test Actual', { isNot: false }, - 'Test Verb', - 'Test Expectation', + 'have title', + '', '', { message: 'Test Message', containing: false } ) }) test('starting message', () => { - expect(actual).toMatch('Test Message\nExpect Test Subject to Test Verb Test Expectation') + expect(actual).toMatch('Test Message\nExpect window to have title') }) test('diff string', () => { @@ -142,19 +143,19 @@ describe('formatMessage', () => { beforeEach(() => { actual = enhanceError( - 'Test Subject', + 'my-element', 'Test Expected', 'Test Actual', { isNot: false }, - 'Test Verb', - 'Test Expectation', - 'Test Arg2', + 'have property', + '', + 'myProp', { message: 'Test Message', containing: false } ) }) test('starting message', () => { - expect(actual).toMatch('Expect Test Subject to Test Verb Test Expectation Test Arg2') + expect(actual).toMatch('Expect my-element to have property myProp') }) test('diff string', () => { @@ -164,7 +165,7 @@ describe('formatMessage', () => { }) }) - describe('numberError', () => { + describe(numberError, () => { test('should return correct message', () => { expect(numberError()).toBe('no params') expect(numberError({ eq: 0 })).toBe(0) @@ -173,4 +174,111 @@ describe('formatMessage', () => { expect(numberError({ gte: 2, lte: 1 })).toBe('>= 2 && <= 1') }) }) + describe(formatFailureMessage, () => { + const subject = 'window' + const expectation = 'have title' + const expectedValueArgument2 = 'myProp' + + const baseResult: CompareResult = { + result: false, + actual: 'actual', + expected: 'expected', + instance: 'browser', + value: 'actualValue' + } + const baseContext: ExpectWebdriverIO.MatcherContext = { + isNot: false, + expectation, + verb: '', + isMultiRemote: false + } + + describe('Browser (not multi-remote) having single compareResults', () => { + test('should return correct message', () => { + const results = [baseResult] + const context = { ...baseContext } + + const message = formatFailureMessage(subject, results, context, '', {}) + + expect(message).toMatch('Expect window to have title') + const diffString = printDiffOrStringify('expected', 'actual', 'Expected', 'Received', true) + expect(message).toMatch(diffString) + }) + }) + + describe('Multi-remote having multiple results', () => { + test('should return correct message for multiple failures', () => { + const results = [ + { + ...baseResult, + actual: 'actual1', + expected: 'expected1', + instance: 'browserA' + }, + { + ...baseResult, + actual: 'actual2', + expected: 'expected2', + instance: 'browserB' + } + ] + const context = { + ...baseContext, + isMultiRemote: true + } + + const message = formatFailureMessage(subject, results, context, '', {}) + + expect(message).toContain('Expect window to have title for remote "browserA"') + const diffString1 = printDiffOrStringify('expected1', 'actual1', 'Expected', 'Received', true) + expect(message).toContain(diffString1) + + expect(message).toContain('Expect window to have title for remote "browserB"') + const diffString2 = printDiffOrStringify('expected2', 'actual2', 'Expected', 'Received', true) + expect(message).toContain(diffString2) + }) + }) + + describe('Options', () => { + test('should handle isNot', () => { + const results = [{ + ...baseResult, + result: true, + actual: 'actual', + expected: 'actual' + }] + const context = { + ...baseContext, + isNot: true + } + + const message = formatFailureMessage(subject, results, context, '', {}) + + expect(message).toMatch('Expect window not to have title') + const diffString = `Expected [not]: ${printExpected('actual')}\n` + + `Received : ${printReceived('actual')}` + expect(message).toMatch(diffString) + }) + + test('should handle message', () => { + const results = [baseResult] + const context = { ...baseContext, expectation: 'have property' } + + const message = formatFailureMessage('my-element', results, context, expectedValueArgument2, { message: 'Custom Message', containing: false }) + + expect(message).toMatch('Custom Message') + expect(message).toMatch('Expect my-element to have property myProp') + expect(message).not.toContain('containing') + }) + + test('should handle containing', () => { + const results = [baseResult] + const context = { ...baseContext, expectation: 'have property' } + + const message = formatFailureMessage('my-element', results, context, expectedValueArgument2, { message: '', containing: true }) + + expect(message).toMatch('Expect my-element to have property myProp containing') + }) + }) + }) }) From e4e69ab4e2b597230a0bd377afc1ff84d01b30f4 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 14:00:39 -0500 Subject: [PATCH 17/30] Add UT for multiRemoteUtil --- src/util/multiRemoteUtil.ts | 6 +- test/util/multiRemoteUtil.test.ts | 119 ++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) create mode 100644 test/util/multiRemoteUtil.test.ts diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts index a81451417..5e777d51b 100644 --- a/src/util/multiRemoteUtil.ts +++ b/src/util/multiRemoteUtil.ts @@ -8,15 +8,15 @@ export function isArray(value: unknown): value is T[] { return Array.isArray(value) } -export const isMultiremote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => { +export const isMultiRemote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => { return (browser as WebdriverIO.MultiRemoteBrowser).isMultiremote === true } export const getInstancesWithExpected = (browsers: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, expectedValues: T): Record => { - if (isMultiremote(browsers)) { + if (isMultiRemote(browsers)) { if (Array.isArray(expectedValues)) { if (expectedValues.length !== browsers.instances.length) { - throw new Error(`Expected values length (${expectedValues.length}) does not match number of browser instances (${browsers.instances.length}) in multiremote setup.`) + throw new Error(`Expected values length (${expectedValues.length}) does not match number of browser instances (${browsers.instances.length}) in multi-remote setup.`) } } // TODO multi-remote support: add support for object like { default: 'title', browserA: 'titleA', browserB: 'titleB' } later diff --git a/test/util/multiRemoteUtil.test.ts b/test/util/multiRemoteUtil.test.ts new file mode 100644 index 000000000..e09524daa --- /dev/null +++ b/test/util/multiRemoteUtil.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { toArray, isArray, isMultiRemote, getInstancesWithExpected } from '../../src/util/multiRemoteUtil.js' + +describe('multiRemoteUtil', () => { + describe(toArray, () => { + it('should return array if input is array', () => { + expect(toArray([1, 2])).toEqual([1, 2]) + }) + + it('should return array with single item if input is not array', () => { + expect(toArray(1)).toEqual([1]) + }) + + it('should handle edge cases', () => { + expect(toArray(undefined)).toEqual([undefined]) + expect(toArray(null)).toEqual([null]) + expect(toArray(false)).toEqual([false]) + expect(toArray(0)).toEqual([0]) + expect(toArray('')).toEqual(['']) + expect(toArray({})).toEqual([{}]) + }) + }) + + describe(isArray, () => { + it('should return true if input is array', () => { + expect(isArray([1, 2])).toBe(true) + }) + + it('should return false if input is not array', () => { + expect(isArray(1)).toBe(false) + }) + }) + + describe(isMultiRemote, () => { + it('should return true if browser is multi-remote', () => { + const browser = { isMultiremote: true } satisfies Partial as WebdriverIO.MultiRemoteBrowser + expect(isMultiRemote(browser)).toBe(true) + }) + + it('should return false if browser is not multi-remote', () => { + const browser = { isMultiremote: false } satisfies Partial as WebdriverIO.Browser + expect(isMultiRemote(browser)).toBe(false) + }) + + it('should return false if isMultiremote property is missing', () => { + const browser = {} satisfies Partial as WebdriverIO.Browser + expect(isMultiRemote(browser)).toBe(false) + }) + }) + + describe(getInstancesWithExpected, () => { + it('should return default instance for single browser', () => { + const browser = { isMultiremote: false } satisfies Partial as WebdriverIO.Browser + const expected = 'expected' + const result = getInstancesWithExpected(browser, expected) + expect(result).toEqual({ + default: { + browser, + expectedValue: expected + } + }) + }) + + describe('Multi-remote', () => { + let browser: WebdriverIO.MultiRemoteBrowser + let getInstance: ( name: string ) => WebdriverIO.Browser + + beforeEach(() => { + getInstance = vi.fn((name) => ({ + capabilities: { browserName: name } + } satisfies Partial as WebdriverIO.Browser)) + browser = { + isMultiremote: true, + instances: ['browserA', 'browserB'], + getInstance + } satisfies Partial as WebdriverIO.MultiRemoteBrowser + }) + + it('should return instances for multi-remote browser with single expected value', () => { + const expected = 'expected' + const result = getInstancesWithExpected(browser, expected) + + expect(result).toEqual({ + browserA: { + browser: { capabilities: { browserName: 'browserA' } }, + expectedValue: expected + }, + browserB: { + browser: { capabilities: { browserName: 'browserB' } }, + expectedValue: expected + } + }) + expect(getInstance).toHaveBeenCalledWith('browserA') + expect(getInstance).toHaveBeenCalledWith('browserB') + }) + + it('should return instances for multi-remote browser with array of expected values', () => { + const expected = ['expectedA', 'expectedB'] + const result = getInstancesWithExpected(browser, expected) + + expect(result).toEqual({ + browserA: { + browser: { capabilities: { browserName: 'browserA' } }, + expectedValue: 'expectedA' + }, + browserB: { + browser: { capabilities: { browserName: 'browserB' } }, + expectedValue: 'expectedB' + } + }) + }) + + it('should throw error if expected values length does not match instances length', () => { + const expected = ['expectedA'] + expect(() => getInstancesWithExpected(browser, expected)).toThrow('Expected values length (1) does not match number of browser instances (2) in multi-remote setup.') + }) + }) + }) +}) From 8da4813ddfa1c7c43d9a9281d4898f608ebd322a Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 14:13:29 -0500 Subject: [PATCH 18/30] Code review --- types/expect-webdriverio.d.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index c14ba8009..7e332ea74 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -99,26 +99,18 @@ interface WdioBrowserMatchers<_R, ActualT> { options?: ExpectWebdriverIO.StringOptions, ) => Promise > - // /** - // * `WebdriverIO.Browser` -> `getTitle` - // */ - // toHaveTitle: FnWhenBrowser< - // ActualT, - // ( - // title: string | RegExp | ExpectWebdriverIO.PartialMatcher, - // options?: ExpectWebdriverIO.StringOptions, - // ) => Promise - // > /** * `WebdriverIO.Browser`, `WebdriverIO.MultiRemoteBrowser` -> `getTitle` */ toHaveTitle: FnWhenBrowserOrMultiRemote< ActualT, + // Browser ( title: string | RegExp | ExpectWebdriverIO.PartialMatcher, options?: ExpectWebdriverIO.StringOptions, ) => Promise, + // MultiRemoteBrowser ( url: MaybeArray>, options?: ExpectWebdriverIO.StringOptions, From 82c7caf2a0ea49281b09033402c716b7497fc99d Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 21:15:41 -0500 Subject: [PATCH 19/30] fix rebranching --- src/matchers/browser/toHaveTitle.ts | 2 - src/utils.ts | 11 +- test/matchers/browser/toHaveTitle.test.ts | 317 ++++++++++++---------- test/matchers/browserMatchers.test.ts | 17 +- test/utils.test.ts | 113 +------- 5 files changed, 186 insertions(+), 274 deletions(-) diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index ded7d8ad0..c95518f84 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -27,8 +27,6 @@ export async function toHaveTitle( const { expectation = 'title', verb = 'have', isNot } = this const context = { expectation, verb, isNot, isMultiRemote: browser.isMultiremote } - console.log('toHaveTitle', { expectedValue, isNot, options }) - await options.beforeAssertion?.({ matcherName: 'toHaveTitle', expectedValue, diff --git a/src/utils.ts b/src/utils.ts index 42281f0ef..85ecf2459 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -6,6 +6,7 @@ import type { WdioElementMaybePromise } from './types.js' import { wrapExpectedWithArray } from './util/elementsUtil.js' import { executeCommand } from './util/executeCommand.js' import { enhanceError, enhanceErrorBe, numberError } from './util/formatMessage.js' +import { toArray } from './util/multiRemoteUtil.js' export type CompareResult = { value: A // actual but sometimes modified (e.g. trimmed, lowercased, etc) @@ -111,19 +112,17 @@ const waitUntilResult = async ( return { pass, results: allResults } } + /** - * Wait for condition to succeed - * For multiple remotes, all conditions must be met - * When using negated condition (isNot=true), we wait for all conditions to be true first, then we negate the real value if it takes time to show up. - * - * @param condition function to that should return true when condition is met + * wait for expectation to succeed + * @param condition function * @param isNot https://jestjs.io/docs/expect#thisisnot * @param options wait, interval, etc */ const waitUntil = async ( condition: () => Promise, isNot = false, - { wait = DEFAULT_OPTIONS.wait, interval = DEFAULT_OPTIONS.interval } = {}, + { wait = DEFAULT_OPTIONS.wait, interval = DEFAULT_OPTIONS.interval } = {} ): Promise => { // single attempt if (wait === 0) { diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts index b18c5db15..def342c77 100644 --- a/test/matchers/browser/toHaveTitle.test.ts +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -18,7 +18,6 @@ vi.mock('@wdio/globals', () => ({ }, multiremotebrowser: { isMultiremote: true, - getTitle: vi.fn().mockResolvedValue(['']), instances: ['browserA'], getInstance: (name: string) => { const instance = multiRemoteBrowserInstances[name] @@ -31,128 +30,98 @@ vi.mock('@wdio/globals', () => ({ })) describe('toHaveTitle', async () => { - const defaultContext = { isNot: false, toHaveTitle } - const goodTitle = 'some Title text' - const wrongTitle = 'some Wrong Title text' + describe('given isNot false', async () => { + const defaultContext = { isNot: false, toHaveTitle } + const goodTitle = 'some Title text' + const wrongTitle = 'some Wrong Title text' - beforeEach(async () => { - beforeAssertion.mockClear() - afterAssertion.mockClear() - }) - - describe('Browser', async () => { beforeEach(async () => { - browser.getTitle = vi.fn().mockResolvedValue(goodTitle) + beforeAssertion.mockClear() + afterAssertion.mockClear() }) - describe('given default usage', async () => { - test('when success', async () => { - const result = await defaultContext.toHaveTitle(browser, goodTitle) - - expect(result.pass).toBe(true) + describe('Browser', async () => { + beforeEach(async () => { + browser.getTitle = vi.fn().mockResolvedValue(goodTitle) }) - test('when failure', async () => { - browser.getTitle = vi.fn().mockResolvedValue(wrongTitle) - const result = await defaultContext.toHaveTitle(browser, goodTitle) - - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title - -Expected: "some Title text" -Received: "some Wrong Title text"` - ) - }) - }) + describe('given default usage', async () => { + test('when success', async () => { + const result = await defaultContext.toHaveTitle(browser, goodTitle) - describe('given before/after assertion hooks and options', async () => { - const options = { - ignoreCase: true, - beforeAssertion, - afterAssertion, - } satisfies ExpectWebdriverIO.StringOptions - - test('when success', async () => { - const result = await defaultContext.toHaveTitle(browser, goodTitle, options) - - expect(result.pass).toBe(true) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options, - }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options, - result, + expect(result.pass).toBe(true) }) - }) - test('when failure', async () => { - browser.getTitle = vi.fn().mockResolvedValue(wrongTitle) - const result = await defaultContext.toHaveTitle(browser, goodTitle, options) + test('when failure', async () => { + browser.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(browser, goodTitle) - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title Expected: "some Title text" Received: "some Wrong Title text"` - ) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, - }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, - result, + ) }) }) - }) - }) - describe('Multi Remote Browsers', async () => { - beforeEach(async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([goodTitle]) - }) + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions - describe('given default usage', async () => { - test('when success', async () => { - const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + test('when success', async () => { + const result = await defaultContext.toHaveTitle(browser, goodTitle, options) - expect(result.pass).toBe(true) - }) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options, + result, + }) + }) - test('when failure', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle]) - const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) + test('when failure', async () => { + browser.getTitle = vi.fn().mockResolvedValue(wrongTitle) + const result = await defaultContext.toHaveTitle(browser, goodTitle, options) - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.pass).toBe(false) + expect(result.message()).toEqual(`Expect window to have title Expected: "some Title text" Received: "some Wrong Title text"` - ) + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: goodTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) }) }) describe('Multi Remote Browsers', async () => { beforeEach(async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([goodTitle]) multiremotebrowser.instances = ['browserA'] browserA.getTitle = vi.fn().mockResolvedValue(goodTitle) browserB.getTitle = vi.fn().mockResolvedValue(goodTitle) }) - describe('given one expected value', async () => { - const goodTitles = [goodTitle, goodTitle] - beforeEach(async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue(goodTitles) - }) - + describe('given default usage', async () => { test('when success', async () => { const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) @@ -170,6 +139,7 @@ Expected: "some Title text" Received: "some Wrong Title text"` ) }) + }) describe('given multiple remote browsers', async () => { beforeEach(async () => { @@ -178,8 +148,7 @@ Received: "some Wrong Title text"` browserB.getTitle = vi.fn().mockResolvedValue(goodTitle) }) - expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + describe('given one expected value', async () => { test('when success', async () => { const result = await defaultContext.toHaveTitle(multiremotebrowser, goodTitle) @@ -296,23 +265,9 @@ Received: "some Wrong Title text"` }) test('when success', async () => { - const result = await defaultContext.toHaveTitle( - multiremotebrowser, - goodTitle, - options, - ) + const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + expect(result.pass).toBe(true) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options, - }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options, - result, - }) }) test('when failure for one browser', async () => { @@ -326,6 +281,7 @@ Received: "some Wrong Title text"` Expected: "some Title text" Received: "some Wrong Title text"` ) + }) test('when failure for multiple browsers', async () => { browserA.getTitle = vi.fn().mockResolvedValue(wrongTitle) @@ -406,52 +362,95 @@ Received: "some Wrong Title text"` result, }) }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: goodTitle, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, - result, - }) }) }) }) + }) + }) - describe('given multiple expected values', async () => { - const goodTitle2 = `${goodTitle} 2` - const goodTitles = [goodTitle, goodTitle2] - const expectedValues = [goodTitle, goodTitle2] + describe('given isNot true', async () => { + const defaultContext = { isNot: true, toHaveTitle } + const aTitle = 'some Title text' + const negatedExpectedTitle = 'some Title text not expected to be' - beforeEach(async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue(goodTitles) - }) + beforeEach(async () => { + beforeAssertion.mockClear() + afterAssertion.mockClear() + }) + + describe('Browser', async () => { + beforeEach(async () => { + browser.getTitle = vi.fn().mockResolvedValue(aTitle) + }) + describe('given default usage', async () => { test('when success', async () => { - const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + const result = await defaultContext.toHaveTitle(browser, negatedExpectedTitle) expect(result.pass).toBe(true) }) - test('when failure for one browser', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, goodTitle2]) - const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + test('when failure', async () => { + browser.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + const result = await defaultContext.toHaveTitle(browser, negatedExpectedTitle) expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.message()).toEqual(`Expect window not to have title -Expected: "some Title text" -Received: "some Wrong Title text"` +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` ) }) + }) - test('when failure for multiple browsers', async () => { - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([wrongTitle, wrongTitle]) - const result = await defaultContext.toHaveTitle(multiremotebrowser, expectedValues) + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle(browser, negatedExpectedTitle, options) + + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options, + result, + }) + }) + + test('when failure', async () => { + browser.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) + const result = await defaultContext.toHaveTitle(browser, negatedExpectedTitle, options) expect(result.pass).toBe(false) - expect(result.message()).toEqual(`Expect window to have title + expect(result.message()).toEqual(`Expect window not to have title -Expected: "some Title text" -Received: "some Wrong Title text" +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be"` + ) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedTitle, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) + }) + }) describe('Multi Remote Browsers', async () => { beforeEach(async () => { @@ -479,13 +478,9 @@ Expected [not]: "some Title text not expected to be" Received : "some Title text not expected to be"` ) }) + }) - describe('given before/after assertion hooks and options', async () => { - const options = { - ignoreCase: true, - beforeAssertion, - afterAssertion, - } satisfies ExpectWebdriverIO.StringOptions + describe('given multiple remote browsers', async () => { beforeEach(async () => { multiremotebrowser.instances = ['browserA', 'browserB'] @@ -507,7 +502,6 @@ Received : "some Title text not expected to be"` test('when failure for one browser', async () => { browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) - multiremotebrowser.getTitle = vi.fn().mockResolvedValue([negatedExpectedTitle, aTitle]) const result = await defaultContext.toHaveTitle(multiremotebrowser, negatedExpectedTitle) expect(result.pass).toBe(false) @@ -592,6 +586,7 @@ Received : "some Title text not expected to be"` }) }) }) + }) describe('given multiple expected values', async () => { const aTitle2 = `${aTitle} 2` @@ -620,6 +615,7 @@ Received : "some Title text not expected to be"` Expected [not]: "some Title text not expected to be" Received : "some Title text not expected to be"` ) + }) test('when failure for multiple browsers', async () => { browserA.getTitle = vi.fn().mockResolvedValue(negatedExpectedTitle) @@ -630,18 +626,41 @@ Received : "some Title text not expected to be"` expect(result.pass).toBe(false) expect(result.message()).toEqual(`Expect window not to have title for remote "browserA" -Expected: "some title text" -Received: "some wrong title text" +Expected [not]: "some Title text not expected to be" +Received : "some Title text not expected to be" Expect window not to have title for remote "browserB" -Expected: "some title text 2" -Received: "some wrong title text"` +Expected [not]: "some Title text 2 not expected to be" +Received : "some Title text 2 not expected to be"` ) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveTitle', - expectedValue: expectedValues, - options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + + describe('given before/after assertion hooks and options', async () => { + const options = { + ignoreCase: true, + beforeAssertion, + afterAssertion, + } satisfies ExpectWebdriverIO.StringOptions + + test('when success', async () => { + const result = await defaultContext.toHaveTitle( + multiremotebrowser, + negatedExpectedValues, + options, + ) + expect(result.pass).toBe(true) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedValues, + options, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: negatedExpectedValues, + options, + result, + }) }) test('when failure', async () => { diff --git a/test/matchers/browserMatchers.test.ts b/test/matchers/browserMatchers.test.ts index 7bff2d032..ca5258602 100644 --- a/test/matchers/browserMatchers.test.ts +++ b/test/matchers/browserMatchers.test.ts @@ -1,12 +1,12 @@ import { vi, test, describe, expect } from 'vitest' import { browser } from '@wdio/globals' -import { getExpectMessage, matcherNameToString, getExpected } from '../__fixtures__/utils.js' +import { getExpectMessage, getReceived, matcherNameToString, getExpected } from '../__fixtures__/utils.js' import * as Matchers from '../../src/matchers.js' vi.mock('@wdio/globals') -const browserMatchers = ['toHaveUrl', 'toHaveTitle'] +const browserMatchers = ['toHaveUrl'] const validText = ' Valid Text ' const wrongText = ' Wrong Text ' @@ -106,7 +106,11 @@ describe('browser matchers', () => { } const result = await fn.call({ isNot: true }, browser, validText, { wait: 0 }) as ExpectWebdriverIO.AssertionResult - expect(result.pass).toBe(true) + expect(getExpectMessage(result.message())).toContain('not') + expect(getExpected(result.message())).toContain('Valid') + expect(getReceived(result.message())).toContain('Wrong') + + expect(result.pass).toBe(false) }) test('not - failure (with wait)', async () => { @@ -127,7 +131,11 @@ describe('browser matchers', () => { } const result = await fn.call({ isNot: true }, browser, validText, { wait: 1 }) as ExpectWebdriverIO.AssertionResult - expect(result.pass).toBe(true) + expect(getExpectMessage(result.message())).toContain('not') + expect(getExpected(result.message())).toContain('Valid') + expect(getReceived(result.message())).toContain('Wrong') + + expect(result.pass).toBe(false) }) test('message', async () => { @@ -137,4 +145,3 @@ describe('browser matchers', () => { }) }) }) - diff --git a/test/utils.test.ts b/test/utils.test.ts index 5c1ddf41d..8f3f9770b 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest' import type { CompareResult } from '../src/utils' -import { compareNumbers, compareObject, compareText, compareTextWithArray, waitUntil, waitUntilResult } from '../src/utils' +import { compareNumbers, compareObject, compareText, compareTextWithArray, waitUntilResult } from '../src/utils' describe('utils', () => { describe('compareText', () => { @@ -159,117 +159,6 @@ describe('utils', () => { expect(compareObject([{ 'foo': 'bar' }], { 'foo': 'bar' }).result).toBe(false) }) }) - describe('waitUntil', () => { - describe('given isNot is false', () => { - const isNot = false - - test('should return true when condition is met immediately', async () => { - const condition = async () => true - const result = await waitUntil(condition, isNot, { wait: 1000, interval: 100 }) - expect(result).toBe(true) - }) - - test('should return false when condition is not met and wait is 0', async () => { - const condition = async () => false - const result = await waitUntil(condition, isNot, { wait: 0 }) - expect(result).toBe(false) - }) - - test('should return true when condition is met within wait time', async () => { - let attempts = 0 - const condition = async () => { - attempts++ - return attempts >= 3 - } - const result = await waitUntil(condition, isNot, { wait: 1000, interval: 50 }) - expect(result).toBe(true) - expect(attempts).toBeGreaterThanOrEqual(3) - }) - - test('should return false when condition is not met within wait time', async () => { - const condition = async () => false - const result = await waitUntil(condition, isNot, { wait: 200, interval: 50 }) - expect(result).toBe(false) - }) - - test('should throw error if condition throws and never recovers', async () => { - const condition = async () => { - throw new Error('Test error') - } - await expect(waitUntil(condition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') - }) - - test('should recover from errors if condition eventually succeeds', async () => { - let attempts = 0 - const condition = async () => { - attempts++ - if (attempts < 3) { - throw new Error('Not ready yet') - } - return true - } - const result = await waitUntil(condition, isNot, { wait: 1000, interval: 50 }) - expect(result).toBe(true) - expect(attempts).toBe(3) - }) - - test('should use default options when not provided', async () => { - const condition = async () => true - const result = await waitUntil(condition) - expect(result).toBe(true) - }) - }) - - describe('given isNot is true', () => { - const isNot = true - - test('should handle isNot flag correctly when condition is true', async () => { - const condition = async () => true - const result = await waitUntil(condition, isNot, { wait: 1000, interval: 100 }) - expect(result).toBe(false) - }) - - test('should handle isNot flag correctly when condition is true and wait is 0', async () => { - const condition = async () => true - const result = await waitUntil(condition, isNot, { wait: 0 }) - expect(result).toBe(false) - }) - - test('should handle isNot flag correctly when condition is false', async () => { - const condition = async () => false - const result = await waitUntil(condition, isNot, { wait: 1000, interval: 100 }) - expect(result).toBe(true) - }) - - test('should handle isNot flag correctly when condition is false and wait is 0', async () => { - const condition = async () => false - const result = await waitUntil(condition, isNot, { wait: 0 }) - expect(result).toBe(true) - }) - - test('should throw error if condition throws and never recovers', async () => { - const condition = async () => { - throw new Error('Test error') - } - await expect(waitUntil(condition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') - }) - - test('should do all the attempts to succeed even with isNot true', async () => { - let attempts = 0 - const condition = async () => { - attempts++ - if (attempts < 3) { - throw new Error('Not ready yet') - } - return true - } - const result = await waitUntil(condition, isNot, { wait: 1000, interval: 50 }) - expect(result).toBe(false) - expect(attempts).toBe(3) - }) - - }) - }) describe('waitUntilResult', () => { const trueCompareResult = { value: 'myValue', actual: 'myValue', expected: 'myValue', result: true } satisfies CompareResult From d0bb3ad2846dfd63f02061c0a4e990fba364a4d4 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 21:24:45 -0500 Subject: [PATCH 20/30] Revert undesired formating changes --- src/utils.ts | 66 ++++++++++++++++++++-------------------------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 85ecf2459..8937989ea 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,22 +20,23 @@ export type CompareResult = { const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const asymmetricMatcher = - typeof Symbol === 'function' && Symbol.for ? Symbol.for('jest.asymmetricMatcher') : 0x13_57_a5 + typeof Symbol === 'function' && Symbol.for + ? Symbol.for('jest.asymmetricMatcher') + : 0x13_57_a5 export function isAsymmetricMatcher(expected: unknown): expected is WdioAsymmetricMatcher { - return (typeof expected === 'object' && + return ( + typeof expected === 'object' && expected && '$$typeof' in expected && 'asymmetricMatch' in expected && expected.$$typeof === asymmetricMatcher && - Boolean(expected.asymmetricMatch)) as boolean + Boolean(expected.asymmetricMatch) + ) as boolean } function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetricMatcher { - return ( - isAsymmetricMatcher(expected) && - ['StringContaining', 'StringNotContaining'].includes(expected.toString()) - ) + return isAsymmetricMatcher(expected) && ['StringContaining', 'StringNotContaining'].includes(expected.toString()) } /** @@ -169,7 +170,7 @@ const waitUntil = async ( async function executeCommandBe( received: WdioElementMaybePromise, command: (el: WebdriverIO.Element) => Promise, - options: ExpectWebdriverIO.CommandOptions, + options: ExpectWebdriverIO.CommandOptions ): ExpectWebdriverIO.AsyncAssertionResult { const { isNot, expectation, verb = 'be' } = this @@ -180,13 +181,13 @@ async function executeCommandBe( this, el, async (element) => ({ result: await command(element as WebdriverIO.Element) }), - options, + options ) el = result.el as WebdriverIO.Element return result.success }, isNot, - options, + options ) const message = enhanceErrorBe(el, pass, this, verb, expectation, options) @@ -221,28 +222,18 @@ const compareNumbers = (actual: number, options: ExpectWebdriverIO.NumberOptions return false } -const DEFAULT_STRING_OPTIONS: ExpectWebdriverIO.StringOptions = { - ignoreCase: false, - trim: true, - containing: false, - atStart: false, - atEnd: false, - atIndex: undefined, - replace: undefined, -} - export const compareText = ( actual: string, expected: string | RegExp | WdioAsymmetricMatcher, { - ignoreCase = DEFAULT_STRING_OPTIONS.ignoreCase, - trim = DEFAULT_STRING_OPTIONS.trim, - containing = DEFAULT_STRING_OPTIONS.containing, - atStart = DEFAULT_STRING_OPTIONS.atStart, - atEnd = DEFAULT_STRING_OPTIONS.atEnd, - atIndex = DEFAULT_STRING_OPTIONS.atIndex, - replace = DEFAULT_STRING_OPTIONS.replace, - }: ExpectWebdriverIO.StringOptions, + ignoreCase = false, + trim = true, + containing = false, + atStart = false, + atEnd = false, + atIndex, + replace, + }: ExpectWebdriverIO.StringOptions ): CompareResult> => { const compareResult: CompareResult> = { value: actual, actual, expected, result: false } let value = actual @@ -337,7 +328,7 @@ export const compareTextWithArray = ( atEnd = false, atIndex, replace, - }: ExpectWebdriverIO.StringOptions, + }: ExpectWebdriverIO.StringOptions ) => { if (typeof actual !== 'string') { return { @@ -421,7 +412,7 @@ export const compareStyle = async ( atEnd = false, atIndex, replace, - }: ExpectWebdriverIO.StringOptions, + }: ExpectWebdriverIO.StringOptions ) => { let result = true const actual: Record = {} @@ -480,23 +471,16 @@ function aliasFn( } export { - aliasFn, - compareNumbers, - enhanceError, - executeCommand, - executeCommandBe, - numberError, - waitUntil, - waitUntilResult, - wrapExpectedWithArray, + aliasFn, compareNumbers, enhanceError, executeCommand, + executeCommandBe, numberError, waitUntil, waitUntilResult, wrapExpectedWithArray } function replaceActual( replace: [string | RegExp, string | Function] | Array<[string | RegExp, string | Function]>, - actual: string, + actual: string ) { const hasMultipleReplacers = (replace as [string | RegExp, string | Function][]).every((r) => - Array.isArray(r), + Array.isArray(r) ) const replacers = hasMultipleReplacers ? (replace as [string | RegExp, string | Function][]) From 27c1dcd77dd6cdc0ddcda1f9ab9ad12b00f4a88c Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 21:36:36 -0500 Subject: [PATCH 21/30] revert undesired changes --- src/utils.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 8937989ea..db30d80fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -350,11 +350,9 @@ export const compareTextWithArray = ( return item.toLowerCase() } if (isStringContainingMatcher(item)) { - return ( - item.toString() === 'StringContaining' - ? expect.stringContaining(item.sample?.toString().toLowerCase()) - : expect.not.stringContaining(item.sample?.toString().toLowerCase()) - ) as WdioAsymmetricMatcher + return (item.toString() === 'StringContaining' + ? expect.stringContaining(item.sample?.toString().toLowerCase()) + : expect.not.stringContaining(item.sample?.toString().toLowerCase())) as WdioAsymmetricMatcher } return item }) From bf8e852c8a50b39293d4d9efa83b0b5e463922b0 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 21:48:27 -0500 Subject: [PATCH 22/30] Code review --- src/utils.ts | 5 ++--- test/matchers/browser/toHaveTitle.test.ts | 1 - 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index db30d80fd..dbe949c3d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -45,7 +45,7 @@ function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetri * When using negated condition (isNot=true), we wait for all conditions to be true first, then we negate the real value if it takes time to show up. * TODO multi-remote support: replace waitUntil in other matchers with this function * - * @param condition function to that should return true when condition is met + * @param condition function(s) that should return compare result(s) when resolved * @param isNot https://jestjs.io/docs/expect#thisisnot * @param options wait, interval, etc */ @@ -86,8 +86,7 @@ const waitUntilResult = async ( // TODO multi-remote support: handle errors per remote more gracefully, so we report failures and throws if all remotes are in errors (and therefore still throw when not multi-remote) await Promise.all( pendingConditions.map(async (pendingResult) => { - const results = toArray(await pendingResult.condition()) - pendingResult.results = results + pendingResult.results = toArray(await pendingResult.condition()) }), ) diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts index def342c77..c60d068ca 100644 --- a/test/matchers/browser/toHaveTitle.test.ts +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -256,7 +256,6 @@ Received: "some Wrong Title text"` describe('given multiple expected values', async () => { const goodTitle2 = `${goodTitle} 2` - // const goodTitles = [goodTitle, goodTitle2] const expectedValues = [goodTitle, goodTitle2] beforeEach(async () => { From 594ad71b390699d3d429f16ba01b58d6763a5bee Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Thu, 1 Jan 2026 21:59:38 -0500 Subject: [PATCH 23/30] revert more undesired changes --- types/expect-webdriverio.d.ts | 351 ++++++++++------------------------ 1 file changed, 99 insertions(+), 252 deletions(-) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index 7e332ea74..ff1e3a6b9 100644 --- a/types/expect-webdriverio.d.ts +++ b/types/expect-webdriverio.d.ts @@ -39,23 +39,12 @@ type ElementArrayPromise = Promise /** * Only Wdio real promise */ -type WdioOnlyPromiseLike = - | ElementPromise - | ElementArrayPromise - | ChainablePromiseElement - | ChainablePromiseArray +type WdioOnlyPromiseLike = ElementPromise | ElementArrayPromise | ChainablePromiseElement | ChainablePromiseArray /** * Only wdio real promise or potential promise usage on element or element array or browser */ -type WdioOnlyMaybePromiseLike = - | ElementPromise - | ElementArrayPromise - | ChainablePromiseElement - | ChainablePromiseArray - | WebdriverIO.Browser - | WebdriverIO.Element - | WebdriverIO.ElementArray +type WdioOnlyMaybePromiseLike = ElementPromise | ElementArrayPromise | ChainablePromiseElement | ChainablePromiseArray | WebdriverIO.Browser | WebdriverIO.Element | WebdriverIO.ElementArray /** * Note we are defining Matchers outside of the namespace as done in jest library until we can make every typing work correctly. @@ -88,17 +77,11 @@ type FnWhenMock = ActualT extends MockPromise | WebdriverIO.Mock ? * When asserting on a browser's properties requiring to be awaited, the return type is a Promise. * When actual is not a browser, the return type is never, so the function cannot be used. */ -interface WdioBrowserMatchers<_R, ActualT> { +interface WdioBrowserMatchers<_R, ActualT>{ /** * `WebdriverIO.Browser` -> `getUrl` */ - toHaveUrl: FnWhenBrowser< - ActualT, - ( - url: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveUrl: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> /** * `WebdriverIO.Browser`, `WebdriverIO.MultiRemoteBrowser` -> `getTitle` @@ -120,13 +103,7 @@ interface WdioBrowserMatchers<_R, ActualT> { /** * `WebdriverIO.Browser` -> `execute` */ - toHaveClipboardText: FnWhenBrowser< - ActualT, - ( - clipboardText: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveClipboardText: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> } /** @@ -142,24 +119,12 @@ interface WdioNetworkMatchers<_R, ActualT> { /** * Check that `WebdriverIO.Mock` was called N times */ - toBeRequestedTimes: FnWhenMock< - ActualT, - ( - times: number | ExpectWebdriverIO.NumberOptions, - options?: ExpectWebdriverIO.NumberOptions, - ) => Promise - > + toBeRequestedTimes: FnWhenMock Promise> /** * Check that `WebdriverIO.Mock` was called with the specific parameters */ - toBeRequestedWith: FnWhenMock< - ActualT, - ( - requestedWith: ExpectWebdriverIO.RequestedWith, - options?: ExpectWebdriverIO.CommandOptions, - ) => Promise - > + toBeRequestedWith: FnWhenMock Promise> } /** @@ -182,54 +147,37 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { /** * `WebdriverIO.Element` -> `isExisting` */ - toBePresent: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBePresent: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `isExisting` */ - toBeExisting: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeExisting: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `getAttribute` */ - toHaveAttribute: FnWhenElementOrArrayLike< - ActualT, - ( - attribute: string, - value?: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveAttribute: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions) + => Promise> /** * `WebdriverIO.Element` -> `getAttribute` */ - toHaveAttr: FnWhenElementOrArrayLike< - ActualT, - ( - attribute: string, - value?: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveAttr: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getAttribute` class * @deprecated since v1.3.1 - use `toHaveElementClass` instead. */ - toHaveClass: FnWhenElementOrArrayLike< - ActualT, - ( - className: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveClass: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getAttribute` class @@ -247,145 +195,103 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { * await expect(element).toHaveElementClass(['btn', 'btn-large']); * ``` */ - toHaveElementClass: FnWhenElementOrArrayLike< - ActualT, - ( - className: string | RegExp | Array | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveElementClass: FnWhenElementOrArrayLike | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getProperty` */ - toHaveElementProperty: FnWhenElementOrArrayLike< - ActualT, - ( - property: string | RegExp | ExpectWebdriverIO.PartialMatcher, - value?: unknown, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveElementProperty: FnWhenElementOrArrayLike, + value?: unknown, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getProperty` value */ - toHaveValue: FnWhenElementOrArrayLike< - ActualT, - ( - value: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveValue: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `isClickable` */ - toBeClickable: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeClickable: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `!isEnabled` */ - toBeDisabled: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeDisabled: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `isDisplayedInViewport` */ - toBeDisplayedInViewport: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeDisplayedInViewport: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `isEnabled` */ - toBeEnabled: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeEnabled: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `isFocused` */ - toBeFocused: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeFocused: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `isSelected` */ - toBeSelected: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeSelected: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `isSelected` */ - toBeChecked: FnWhenElementOrArrayLike< - ActualT, - (options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toBeChecked: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `$$('./*').length` * supports less / greater then or equals to be passed in options */ - toHaveChildren: FnWhenElementOrArrayLike< - ActualT, - ( - size?: number | ExpectWebdriverIO.NumberOptions, - options?: ExpectWebdriverIO.NumberOptions, - ) => Promise - > + toHaveChildren: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `getAttribute` href */ - toHaveHref: FnWhenElementOrArrayLike< - ActualT, - ( - href: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveHref: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getAttribute` href */ - toHaveLink: FnWhenElementOrArrayLike< - ActualT, - ( - href: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveLink: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getProperty` value */ - toHaveId: FnWhenElementOrArrayLike< - ActualT, - ( - id: string | RegExp | ExpectWebdriverIO.PartialMatcher, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveId: FnWhenElementOrArrayLike, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getSize` value */ - toHaveSize: FnWhenElementOrArrayLike< - ActualT, - (size: { height: number; width: number }, options?: ExpectWebdriverIO.StringOptions) => Promise - > + toHaveSize: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `getText` @@ -406,66 +312,43 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { * await expect(elem).toHaveText(['Coffee', 'Tea', 'Milk']) * ``` */ - toHaveText: FnWhenElementOrArrayLike< - ActualT, - ( - text: - | string - | RegExp - | ExpectWebdriverIO.PartialMatcher - | Array>, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveText: FnWhenElementOrArrayLike | Array>, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getHTML` * Element's html equals the html provided */ - toHaveHTML: FnWhenElementOrArrayLike< - ActualT, - ( - html: string | RegExp | ExpectWebdriverIO.PartialMatcher | Array, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveHTML: FnWhenElementOrArrayLike | Array, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getComputedLabel` * Element's computed label equals the computed label provided */ - toHaveComputedLabel: FnWhenElementOrArrayLike< - ActualT, - ( - computedLabel: - | string - | RegExp - | ExpectWebdriverIO.PartialMatcher - | Array, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveComputedLabel: FnWhenElementOrArrayLike | Array, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getComputedRole` * Element's computed role equals the computed role provided */ - toHaveComputedRole: FnWhenElementOrArrayLike< - ActualT, - ( - computedRole: string | RegExp | ExpectWebdriverIO.PartialMatcher | Array, - options?: ExpectWebdriverIO.StringOptions, - ) => Promise - > + toHaveComputedRole: FnWhenElementOrArrayLike | Array, + options?: ExpectWebdriverIO.StringOptions + ) => Promise> /** * `WebdriverIO.Element` -> `getSize('width')` * Element's width equals the width provided */ - toHaveWidth: FnWhenElementOrArrayLike< - ActualT, - (width: number, options?: ExpectWebdriverIO.CommandOptions) => Promise - > + toHaveWidth: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `getSize('height')` or `getSize()` @@ -480,21 +363,15 @@ interface WdioElementOrArrayMatchers<_R, ActualT = unknown> { * await expect(element).toHaveHeight({ height: 42, width: 42 }) * ``` */ - toHaveHeight: FnWhenElementOrArrayLike< - ActualT, - ( - heightOrSize: number | { height: number; width: number }, - options?: ExpectWebdriverIO.CommandOptions, - ) => Promise - > + toHaveHeight: FnWhenElementOrArrayLike Promise> /** * `WebdriverIO.Element` -> `getAttribute("style")` */ - toHaveStyle: FnWhenElementOrArrayLike< - ActualT, - (style: { [key: string]: string }, options?: ExpectWebdriverIO.StringOptions) => Promise - > + toHaveStyle: FnWhenElementOrArrayLike Promise> } /** @@ -508,13 +385,10 @@ interface WdioElementArrayOnlyMatchers<_R, ActualT = unknown> { * `WebdriverIO.ElementArray` -> `$$('...').length` * supports less / greater then or equals to be passed in options */ - toBeElementsArrayOfSize: FnWhenElementArrayLike< - ActualT, - ( - size: number | ExpectWebdriverIO.NumberOptions, - options?: ExpectWebdriverIO.NumberOptions, - ) => Promise & Promise - > + toBeElementsArrayOfSize: FnWhenElementArrayLike Promise & Promise> } /** @@ -530,32 +404,24 @@ interface WdioJestOverloadedMatchers<_R, ActualT> { * snapshot matcher * @param label optional snapshot label */ - toMatchSnapshot(label?: string): ActualT extends WdioPromiseLike ? Promise : void + toMatchSnapshot(label?: string): ActualT extends WdioPromiseLike ? Promise : void; /** * inline snapshot matcher * @param snapshot snapshot string (autogenerated if not specified) * @param label optional snapshot label */ - toMatchInlineSnapshot( - snapshot?: string, - label?: string, - ): ActualT extends WdioPromiseLike ? Promise : void + toMatchInlineSnapshot(snapshot?: string, label?: string): ActualT extends WdioPromiseLike ? Promise : void; } /** * All the specific WebDriverIO only matchers, excluding the generic matchers from the expect library. */ -type WdioCustomMatchers = WdioJestOverloadedMatchers & - WdioBrowserMatchers & - WdioElementOrArrayMatchers & - WdioElementArrayOnlyMatchers & - WdioNetworkMatchers +type WdioCustomMatchers = WdioJestOverloadedMatchers & WdioBrowserMatchers & WdioElementOrArrayMatchers & WdioElementArrayOnlyMatchers & WdioNetworkMatchers /** * All the matchers that WebdriverIO Library supports including the generic matchers from the expect library. */ -type WdioMatchers, ActualT> = WdioCustomMatchers & - ExpectLibMatchers +type WdioMatchers, ActualT> = WdioCustomMatchers & ExpectLibMatchers /** * Expects specific to WebdriverIO, excluding the generic expect matchers. @@ -567,11 +433,7 @@ interface WdioCustomExpect { * All failures are collected and reported at the end of the test * Note: Until fixed, soft only support wdio custom matchers, and not the `expect` library matchers. Moreover, it always returns a Promise. */ - soft( - actual: T, - ): T extends PromiseLike - ? ExpectWebdriverIO.MatchersAndInverse, T> & ExpectWebdriverIO.PromiseMatchers - : ExpectWebdriverIO.MatchersAndInverse + soft(actual: T): T extends PromiseLike ? ExpectWebdriverIO.MatchersAndInverse, T> & ExpectWebdriverIO.PromiseMatchers : ExpectWebdriverIO.MatchersAndInverse; /** * Get all current soft assertion failures @@ -645,11 +507,7 @@ declare namespace ExpectWebdriverIO { * `AsymmetricMatchers` and `Inverse` needs to be defined and be before the `expect` library Expect (aka `WdioExpect`). * The above allows to have custom asymmetric matchers under the `ExpectWebdriverIO` namespace. */ - interface Expect - extends - ExpectWebdriverIO.AsymmetricMatchers, - ExpectLibInverse, - WdioExpect { + interface Expect extends ExpectWebdriverIO.AsymmetricMatchers, ExpectLibInverse, WdioExpect { /** * The `expect` function is used every time you want to test a value. * You will rarely call `expect` by itself. @@ -662,31 +520,20 @@ declare namespace ExpectWebdriverIO { * * @param actual The value to apply matchers against. */ - ( - actual: T, - ): T extends PromiseLike - ? ExpectWebdriverIO.MatchersAndInverse & ExpectWebdriverIO.PromiseMatchers - : ExpectWebdriverIO.MatchersAndInverse + (actual: T): T extends PromiseLike ? ExpectWebdriverIO.MatchersAndInverse & ExpectWebdriverIO.PromiseMatchers : ExpectWebdriverIO.MatchersAndInverse; } interface Matchers, T> extends WdioMatchers {} interface AsymmetricMatchers extends WdioAsymmetricMatchers {} - interface InverseAsymmetricMatchers extends Omit< - ExpectWebdriverIO.AsymmetricMatchers, - 'anything' | 'any' - > {} + interface InverseAsymmetricMatchers extends Omit {} /** * End of block overloading types from the expect library. */ - type MatchersAndInverse, ActualT> = ExpectWebdriverIO.Matchers< - R, - ActualT - > & - ExpectLibInverse> + type MatchersAndInverse, ActualT> = ExpectWebdriverIO.Matchers & ExpectLibInverse> /** * Take from expect library @@ -742,7 +589,7 @@ declare namespace ExpectWebdriverIO { beforeStep(step: PickleStep, scenario: Scenario): void // eslint-disable-next-line @typescript-eslint/no-explicit-any afterTest(test: Test, context: any, result: TestResult): void - afterStep(step: PickleStep, scenario: Scenario, result: { passed: boolean; error?: Error }): void + afterStep(step: PickleStep, scenario: Scenario, result: { passed: boolean, error?: Error }): void } interface AssertionResult extends ExpectLibSyncExpectationResult {} @@ -759,7 +606,7 @@ declare namespace ExpectWebdriverIO { /** * name of the matcher, e.g. `toHaveText` or `toBeClickable` */ - matcherName: keyof Matchers + matcherName: keyof Matchers, /** * Value that the user has passed in * @@ -771,7 +618,7 @@ declare namespace ExpectWebdriverIO { * ``` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - expectedValue?: any + expectedValue?: any, /** * Options that the user has passed in, e.g. `expect(el).toHaveText('foo', { ignoreCase: true })` -> `{ ignoreCase: true }` */ From 870b248a6af0dcb6bd79b352484850452cebcb1d Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Fri, 2 Jan 2026 08:29:01 -0500 Subject: [PATCH 24/30] Final code review --- docs/MultiRemote.md | 50 +++++++++++++++--- src/matchers/browser/toHaveTitle.ts | 14 ++--- src/util/multiRemoteUtil.ts | 19 +++---- src/utils.ts | 8 +-- test/util/multiRemoteUtil.test.ts | 22 +++----- test/utils.test.ts | 80 ++++++++++++++--------------- 6 files changed, 110 insertions(+), 83 deletions(-) diff --git a/docs/MultiRemote.md b/docs/MultiRemote.md index 9e0254d47..a66fa76f9 100644 --- a/docs/MultiRemote.md +++ b/docs/MultiRemote.md @@ -4,7 +4,7 @@ Multi-remote support is in active development. ## Usage -By default, multi-remote queries (e.g., `getTitle`) fetch data from all remotes, simplifying tests where browsers share behavior. +By default, multi-remote matchers fetch data (e.g., `getTitle`) from all remotes, simplifying tests where browsers share the same behavior. Use the typed global constants: ```ts @@ -37,6 +37,18 @@ export const config: WebdriverIO.MultiremoteConfig = { } ``` +And an `it` test like: +```ts +import { multiremotebrowser as multiRemoteBrowser } from '@wdio/globals' + +it('should have title "My Site Title"', async function () { + await multiRemoteBrowser.url('https://mysite.com') + + // ... assertions +}) +``` + + ## Single Expected Value To test all remotes against the same value, pass a single expected value. ```ts @@ -80,7 +92,7 @@ To assert all remotes with a default value, overriding specific ones: - Options (e.g., `StringOptions`) apply globally. - Alpha support is limited to the `toHaveTitle` browser matcher. - Element matchers are planned. -- Assertions currently throw on the first error. Future updates will report thrown errors as failures and if all remotes are in error it will throw. +- Assertions currently throw on the first error. Future updates will report thrown errors as failures, and will only throw if all remotes fail. - SoftAssertions, snapshot services and network matchers might come after. ## Alternatives @@ -95,25 +107,49 @@ Mocha Parameterized Example describe('Multiremote test', async () => { multiRemoteBrowser.instances.forEach(function (instance) { describe(`Test ${instance}`, function () { - it('should have title "The Internet"', async function () { + it('should have title "My Site Title"', async function () { const browser = multiRemoteBrowser.getInstance(instance) await browser.url('https://mysite.com') - await expect(browser).toHaveTitle("The Internet") + await expect(browser).toHaveTitle("My Site Title") }) }) }) }) ``` -### Direct Instance Access +### Direct Instance Access (TypeScript) By extending the WebdriverIO `namespace` in TypeScript (see [documentation](https://webdriver.io/docs/multiremote/#extending-typescript-types)), you can directly access each instance and use `expect` on them. ```ts it('should have title per browsers', async () => { await multiRemoteBrowser.url('https://mysite.com') - await expect(multiRemoteBrowser.myChromeBrowser).toHaveTitle('The Internet') - await expect(multiRemoteBrowser.myFirefoxBrowser).toHaveTitle('The Internet') + await expect(multiRemoteBrowser.myChromeBrowser).toHaveTitle('My Chrome Site Title') + await expect(multiRemoteBrowser.myFirefoxBrowser).toHaveTitle('My Firefox Site Title') }) ``` +Required configuration: + +File `type.d.ts` +```ts +declare namespace WebdriverIO { + interface MultiRemoteBrowser { + myChromeBrowser: WebdriverIO.Browser + myFirefoxBrowser: WebdriverIO.Browser + } +} +``` + +In `tsconfig.json` +```json +{ + "compilerOptions": { + ... + }, + "include": [ + ... + "type.d.ts" + ] +} +``` diff --git a/src/matchers/browser/toHaveTitle.ts b/src/matchers/browser/toHaveTitle.ts index c95518f84..4e76f467d 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -1,7 +1,7 @@ -import { compareText, waitUntilResult } from '../../utils.js' +import { compareText, waitUntilResultSucceed } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' import type { MaybeArray } from '../../util/multiRemoteUtil.js' -import { getInstancesWithExpected } from '../../util/multiRemoteUtil.js' +import { mapExpectedValueWithInstances } from '../../util/multiRemoteUtil.js' import { formatFailureMessage } from '../../util/formatMessage.js' type ExpectedValueType = string | RegExp | WdioAsymmetricMatcher @@ -33,17 +33,17 @@ export async function toHaveTitle( options, }) - const browsers = getInstancesWithExpected(browser, expectedValue) + const browsersWithExpected = mapExpectedValueWithInstances(browser, expectedValue) - const conditions = Object.entries(browsers).map(([instance, { browser, expectedValue: expected }]) => async () => { + const conditions = Object.entries(browsersWithExpected).map(([instanceName, { browser, expectedValue: expected }]) => async () => { const actual = await browser.getTitle() - const result = compareText(actual, expected as ExpectedValueType, options) - result.instance = instance + const result = compareText(actual, expected, options) + result.instance = instanceName return result }) - const conditionsResults = await waitUntilResult( + const conditionsResults = await waitUntilResultSucceed( conditions, isNot, options, diff --git a/src/util/multiRemoteUtil.ts b/src/util/multiRemoteUtil.ts index 5e777d51b..ca6945a4f 100644 --- a/src/util/multiRemoteUtil.ts +++ b/src/util/multiRemoteUtil.ts @@ -4,15 +4,16 @@ export const toArray = (value: T | T[] | MaybeArray): T[] => (Array.isArra export type MaybeArray = T | T[] -export function isArray(value: unknown): value is T[] { - return Array.isArray(value) -} - export const isMultiRemote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => { return (browser as WebdriverIO.MultiRemoteBrowser).isMultiremote === true } -export const getInstancesWithExpected = (browsers: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, expectedValues: T): Record => { +type BrowserWithExpected = Record + +export const mapExpectedValueWithInstances = (browsers: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, expectedValues: T | MaybeArray): BrowserWithExpected => { if (isMultiRemote(browsers)) { if (Array.isArray(expectedValues)) { if (expectedValues.length !== browsers.instances.length) { @@ -21,15 +22,15 @@ export const getInstancesWithExpected = (browsers: WebdriverIO.Browser | Webd } // TODO multi-remote support: add support for object like { default: 'title', browserA: 'titleA', browserB: 'titleB' } later - const browsersWithExpected = browsers.instances.reduce((acc, instance, index) => { + const browsersWithExpected = browsers.instances.reduce((acc: BrowserWithExpected, instance, index) => { const browser = browsers.getInstance(instance) - const expectedValue = Array.isArray(expectedValues) ? expectedValues[index] : expectedValues + const expectedValue: T = Array.isArray(expectedValues) ? expectedValues[index] : expectedValues acc[instance] = { browser, expectedValue } return acc - }, {} as Record) + }, {}) return browsersWithExpected } // TODO multi-remote support: using default could clash if someone use name default, to review later - return { default: { browser: browsers, expectedValue: expectedValues } } + return { default: { browser: browsers, expectedValue: expectedValues as T } } } diff --git a/src/utils.ts b/src/utils.ts index dbe949c3d..b0ac4d0f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -49,14 +49,14 @@ function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetri * @param isNot https://jestjs.io/docs/expect#thisisnot * @param options wait, interval, etc */ -const waitUntilResult = async ( +const waitUntilResultSucceed = async ( condition: (() => Promise | CompareResult[]>) | (() => Promise>)[], isNot = false, { wait = DEFAULT_OPTIONS.wait, interval = DEFAULT_OPTIONS.interval } = {}, ): Promise<{ pass: boolean, results: CompareResult[] }> => { /** * Using array algorithm to handle both single and multiple conditions uniformly - * Technically, this is an o(n3) operation, but practically, we process either a single promise with Array or an Array of promises. Review later if we can simplify and only have an array of promises + * Technically, this is an o(n3) operation, but practically, we process either a single promise returning Array or an Array of promises. Review later if we can simplify and only have an array of promises */ const conditions = toArray(condition) // single attempt @@ -257,7 +257,7 @@ export const compareText = ( expectedValue.toString() === 'StringContaining' ? expect.stringContaining(expectedValue.sample?.toString().toLowerCase()) : expect.not.stringContaining(expectedValue.sample?.toString().toLowerCase()) - ) as WdioAsymmetricMatcher + ) satisfies Partial> as WdioAsymmetricMatcher } } @@ -469,7 +469,7 @@ function aliasFn( export { aliasFn, compareNumbers, enhanceError, executeCommand, - executeCommandBe, numberError, waitUntil, waitUntilResult, wrapExpectedWithArray + executeCommandBe, numberError, waitUntil, waitUntilResultSucceed, wrapExpectedWithArray } function replaceActual( diff --git a/test/util/multiRemoteUtil.test.ts b/test/util/multiRemoteUtil.test.ts index e09524daa..eead1f453 100644 --- a/test/util/multiRemoteUtil.test.ts +++ b/test/util/multiRemoteUtil.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { toArray, isArray, isMultiRemote, getInstancesWithExpected } from '../../src/util/multiRemoteUtil.js' +import { toArray, isMultiRemote, mapExpectedValueWithInstances } from '../../src/util/multiRemoteUtil.js' describe('multiRemoteUtil', () => { describe(toArray, () => { @@ -21,16 +21,6 @@ describe('multiRemoteUtil', () => { }) }) - describe(isArray, () => { - it('should return true if input is array', () => { - expect(isArray([1, 2])).toBe(true) - }) - - it('should return false if input is not array', () => { - expect(isArray(1)).toBe(false) - }) - }) - describe(isMultiRemote, () => { it('should return true if browser is multi-remote', () => { const browser = { isMultiremote: true } satisfies Partial as WebdriverIO.MultiRemoteBrowser @@ -48,11 +38,11 @@ describe('multiRemoteUtil', () => { }) }) - describe(getInstancesWithExpected, () => { + describe(mapExpectedValueWithInstances, () => { it('should return default instance for single browser', () => { const browser = { isMultiremote: false } satisfies Partial as WebdriverIO.Browser const expected = 'expected' - const result = getInstancesWithExpected(browser, expected) + const result = mapExpectedValueWithInstances(browser, expected) expect(result).toEqual({ default: { browser, @@ -78,7 +68,7 @@ describe('multiRemoteUtil', () => { it('should return instances for multi-remote browser with single expected value', () => { const expected = 'expected' - const result = getInstancesWithExpected(browser, expected) + const result = mapExpectedValueWithInstances(browser, expected) expect(result).toEqual({ browserA: { @@ -96,7 +86,7 @@ describe('multiRemoteUtil', () => { it('should return instances for multi-remote browser with array of expected values', () => { const expected = ['expectedA', 'expectedB'] - const result = getInstancesWithExpected(browser, expected) + const result = mapExpectedValueWithInstances(browser, expected) expect(result).toEqual({ browserA: { @@ -112,7 +102,7 @@ describe('multiRemoteUtil', () => { it('should throw error if expected values length does not match instances length', () => { const expected = ['expectedA'] - expect(() => getInstancesWithExpected(browser, expected)).toThrow('Expected values length (1) does not match number of browser instances (2) in multi-remote setup.') + expect(() => mapExpectedValueWithInstances(browser, expected)).toThrow('Expected values length (1) does not match number of browser instances (2) in multi-remote setup.') }) }) }) diff --git a/test/utils.test.ts b/test/utils.test.ts index 8f3f9770b..0a33d5687 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from 'vitest' import type { CompareResult } from '../src/utils' -import { compareNumbers, compareObject, compareText, compareTextWithArray, waitUntilResult } from '../src/utils' +import { compareNumbers, compareObject, compareText, compareTextWithArray, waitUntilResultSucceed } from '../src/utils' describe('utils', () => { describe('compareText', () => { @@ -180,7 +180,7 @@ describe('utils', () => { const isNot = false test('should return true when condition is met immediately', async () => { - const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(trueCondition, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: true, @@ -189,7 +189,7 @@ describe('utils', () => { }) test('should return false when condition is not met and wait is 0', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(falseCondition, isNot, { wait: 0 }) expect(result).toEqual({ pass: false, @@ -204,7 +204,7 @@ describe('utils', () => { return attempts >= 3 ? trueCompareResult : falseCompareResult } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed(condition, isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: true, @@ -214,7 +214,7 @@ describe('utils', () => { }) test('should return false when condition is not met within wait time', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 200, interval: 50 }) + const result = await waitUntilResultSucceed(falseCondition, isNot, { wait: 200, interval: 50 }) expect(result).toEqual({ pass: false, @@ -223,7 +223,7 @@ describe('utils', () => { }) test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + await expect(waitUntilResultSucceed(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') }) test('should recover from errors if condition eventually succeeds', async () => { @@ -236,7 +236,7 @@ describe('utils', () => { return trueCompareResult } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed(condition, isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: true, @@ -246,7 +246,7 @@ describe('utils', () => { }) test('should use default options when not provided', async () => { - const result = await waitUntilResult(trueCondition) + const result = await waitUntilResultSucceed(trueCondition) expect(result).toEqual({ pass: true, @@ -259,7 +259,7 @@ describe('utils', () => { const isNot = true test('should handle isNot flag correctly when condition is true', async () => { - const result = await waitUntilResult(trueCondition, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(trueCondition, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: false, @@ -268,7 +268,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is true and wait is 0', async () => { - const result = await waitUntilResult(trueCondition, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(trueCondition, isNot, { wait: 0 }) expect(result).toEqual({ pass: false, @@ -277,7 +277,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is false', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(falseCondition, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: true, @@ -286,7 +286,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is false and wait is 0', async () => { - const result = await waitUntilResult(falseCondition, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(falseCondition, isNot, { wait: 0 }) expect(result).toEqual({ pass: true, @@ -295,7 +295,7 @@ describe('utils', () => { }) test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + await expect(waitUntilResultSucceed(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') }) test('should do all the attempts to succeed even with isNot true', async () => { @@ -307,7 +307,7 @@ describe('utils', () => { } return trueCompareResult } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed(condition, isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: false, results: [{ ...trueCompareResult, pass : false }], @@ -329,7 +329,7 @@ describe('utils', () => { const isNot = false test('should return true when condition is met immediately', async () => { - const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(trueConditions, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: true, @@ -338,7 +338,7 @@ describe('utils', () => { }) test('should return false when condition is not met and wait is 0', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 0 }) expect(result).toEqual({ pass: false, @@ -353,7 +353,7 @@ describe('utils', () => { return attempts >= 3 ? trueConditions() : falseConditions() } - const result = await waitUntilResult(conditions, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed(conditions, isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: true, @@ -363,7 +363,7 @@ describe('utils', () => { }) test('should return false when condition is not met within wait time', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 200, interval: 50 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 200, interval: 50 }) expect(result).toEqual({ pass: false, @@ -372,7 +372,7 @@ describe('utils', () => { }) test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + await expect(waitUntilResultSucceed(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') }) test('should recover from errors if condition eventually succeeds', async () => { @@ -385,7 +385,7 @@ describe('utils', () => { return trueConditions() } - const result = await waitUntilResult(condition, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed(condition, isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: true, @@ -395,7 +395,7 @@ describe('utils', () => { }) test('should use default options when not provided', async () => { - const result = await waitUntilResult(trueConditions) + const result = await waitUntilResultSucceed(trueConditions) expect(result).toEqual({ pass: true, @@ -408,7 +408,7 @@ describe('utils', () => { const isNot = true test('should handle isNot flag correctly when condition is true', async () => { - const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(trueConditions, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: false, @@ -417,7 +417,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is true and wait is 0', async () => { - const result = await waitUntilResult(trueConditions, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(trueConditions, isNot, { wait: 0 }) expect(result).toEqual({ pass: false, @@ -426,7 +426,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is false', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: true, @@ -435,7 +435,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is false and wait is 0', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 0 }) expect(result).toEqual({ pass: true, @@ -444,7 +444,7 @@ describe('utils', () => { }) test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + await expect(waitUntilResultSucceed(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') }) test('should do all the attempts to succeed even with isNot true', async () => { @@ -456,7 +456,7 @@ describe('utils', () => { } return trueConditions() } - const result = await waitUntilResult(conditions, isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed(conditions, isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: false, results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], @@ -480,7 +480,7 @@ describe('utils', () => { const isNot = false test('should return true when condition is met immediately', async () => { - const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(trueConditions, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: true, @@ -489,7 +489,7 @@ describe('utils', () => { }) test('should return false when condition is not met and wait is 0', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 0 }) expect(result).toEqual({ pass: false, @@ -509,7 +509,7 @@ describe('utils', () => { return attempts2 >= 3 ? trueCondition() : falseCondition() } - const result = await waitUntilResult([condition1, condition2], isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed([condition1, condition2], isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: true, @@ -520,7 +520,7 @@ describe('utils', () => { }) test('should return false when condition is not met within wait time', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 200, interval: 50 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 200, interval: 50 }) expect(result).toEqual({ pass: false, @@ -529,7 +529,7 @@ describe('utils', () => { }) test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + await expect(waitUntilResultSucceed(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') }) test('should recover from errors if condition eventually succeeds', async () => { @@ -551,7 +551,7 @@ describe('utils', () => { return trueCondition() } - const result = await waitUntilResult([condition1, condition2], isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed([condition1, condition2], isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: true, @@ -562,7 +562,7 @@ describe('utils', () => { }) test('should use default options when not provided', async () => { - const result = await waitUntilResult(trueConditions) + const result = await waitUntilResultSucceed(trueConditions) expect(result).toEqual({ pass: true, @@ -575,7 +575,7 @@ describe('utils', () => { const isNot = true test('should handle isNot flag correctly when condition is true', async () => { - const result = await waitUntilResult(trueConditions, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(trueConditions, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: false, @@ -584,7 +584,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is true and wait is 0', async () => { - const result = await waitUntilResult(trueConditions, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(trueConditions, isNot, { wait: 0 }) expect(result).toEqual({ pass: false, @@ -593,7 +593,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is false', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 1000, interval: 100 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 1000, interval: 100 }) expect(result).toEqual({ pass: true, @@ -602,7 +602,7 @@ describe('utils', () => { }) test('should handle isNot flag correctly when condition is false and wait is 0', async () => { - const result = await waitUntilResult(falseConditions, isNot, { wait: 0 }) + const result = await waitUntilResultSucceed(falseConditions, isNot, { wait: 0 }) expect(result).toEqual({ pass: true, @@ -611,7 +611,7 @@ describe('utils', () => { }) test('should throw error if condition throws and never recovers', async () => { - await expect(waitUntilResult(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') + await expect(waitUntilResultSucceed(errorCondition, isNot, { wait: 200, interval: 50 })).rejects.toThrow('Test error') }) test('should do all the attempts to succeed even with isNot true', async () => { @@ -632,7 +632,7 @@ describe('utils', () => { return trueCondition() } - const result = await waitUntilResult([condition1, condition2], isNot, { wait: 1000, interval: 50 }) + const result = await waitUntilResultSucceed([condition1, condition2], isNot, { wait: 1000, interval: 50 }) expect(result).toEqual({ pass: false, From 3fa95d52c63a526507aed62d47756ef9d33ed9aa Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Jan 2026 07:56:42 -0500 Subject: [PATCH 25/30] Fix rebase --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index d25d1918b..dbc81bba3 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,7 @@ "ts:jasmine-async": "cd test-types/jasmine-async && tsc --project ./tsconfig.json", "checks:all": "npm run build && npm run compile && npm run tsc:root-types && npm run test && npm run ts", "watch": "npm run compile -- --watch", - "prepare": "husky install", - "checks:all": "npm run build && npm run compile && npm run tsc:root-types && npm run test && npm run ts" + "prepare": "husky install" }, "dependencies": { "@vitest/snapshot": "^4.0.16", From 1fd463b83414e9553538f3fc3a8da448b8280cf1 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Jan 2026 08:03:19 -0500 Subject: [PATCH 26/30] Add missing typing test for toBeDisplayed --- test-types/mocha/types-mocha.test.ts | 46 ++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/test-types/mocha/types-mocha.test.ts b/test-types/mocha/types-mocha.test.ts index eb168791e..951b1cda4 100644 --- a/test-types/mocha/types-mocha.test.ts +++ b/test-types/mocha/types-mocha.test.ts @@ -162,6 +162,52 @@ describe('type assertions', () => { }) }) + describe('toBeDisplayed', () => { + const options: ExpectWebdriverIO.ToBeDisplayedOptions = { + withinViewport: true, + contentVisibilityAuto: true, + opacityProperty: true, + wait: 0, + visibilityProperty: true, + message: 'Custom error message' + } + it('should be supported correctly', async () => { + // Element + expectPromiseVoid = expect(element).toBeDisplayed() + expectPromiseVoid = expect(element).not.toBeDisplayed() + expectPromiseVoid = expect(element).toBeDisplayed(options) + expectPromiseVoid = expect(element).not.toBeDisplayed(options) + + // Element array + expectPromiseVoid = expect(elementArray).toBeDisplayed() + expectPromiseVoid = expect(elementArray).not.toBeDisplayed() + + // Chainable element + expectPromiseVoid = expect(chainableElement).toBeDisplayed() + expectPromiseVoid = expect(chainableElement).not.toBeDisplayed() + + // Chainable element array + expectPromiseVoid = expect(chainableArray).toBeDisplayed() + expectPromiseVoid = expect(chainableArray).not.toBeDisplayed() + + // @ts-expect-error + expectVoid = expect(element).toBeDisplayed() + // @ts-expect-error + expectVoid = expect(element).not.toBeDisplayed() + }) + + it('should have ts errors when actual is not an element', async () => { + // @ts-expect-error + await expect(browser).toBeDisplayed() + // @ts-expect-error + await expect(browser).not.toBeDisplayed() + // @ts-expect-error + await expect(true).toBeDisplayed() + // @ts-expect-error + await expect(true).not.toBeDisplayed() + }) + }) + describe('toHaveText', () => { it('should be supported correctly', async () => { expectPromiseVoid = expect(element).toHaveText('text') From 8f91bf3a97bd342225e27e675d6466a3d911aa9e Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Jan 2026 08:05:41 -0500 Subject: [PATCH 27/30] Add doc and future task for multi-remote --- src/util/formatMessage.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index 91aab88f5..c9610ff34 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -98,6 +98,10 @@ export const enhanceError = ( return msg } +/** + * Formats failure message for multiple compare results + * TODO multi-remote support: Replace enhanceError with this one everywhere + */ export const formatFailureMessage = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, compareResults: CompareResult>[], From a848e2dbc68049632d7005b23f8f5a43b0ce8320 Mon Sep 17 00:00:00 2001 From: David Prevost <77302423+dprevost-LMI@users.noreply.github.com> Date: Mon, 5 Jan 2026 08:09:29 -0500 Subject: [PATCH 28/30] Apply suggestions from code review --- src/util/formatMessage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index c9610ff34..49c28a11a 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -151,7 +151,7 @@ export const formatFailureMessage = ( /** * Example of below message (multi-remote + isNot case): * ``` - * Expect window to have title for remote "browserA" + * Expect window not to have title for remote "browserA" * * Expected not: "some Title text" * Received: "some Wrong Title text" From 3fcc76d8c079f32da3cf562e682d7ffed33ab2bc Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Jan 2026 08:45:35 -0500 Subject: [PATCH 29/30] fix: backport isNot ambiguous failure message from PR #1987 Backporting fixes of issue https://github.com/webdriverio/expect-webdriverio/pull/1986 and PR https://github.com/webdriverio/expect-webdriverio/pull/1987 to multi-remote --- src/util/formatMessage.ts | 44 ++++++++++++--------- test/util/formatMessage.test.ts | 69 +++++++++++++++++++++++---------- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index 49c28a11a..c59a54458 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -40,9 +40,7 @@ export const getSelectors = (el: WebdriverIO.Element | WdioElements) => { return selectors.reverse().join('.') } -export const not = (isNot: boolean): string => { - return `${isNot ? 'not ' : ''}` -} +const not = (isNot: boolean): string => `${isNot ? 'not ' : ''}` export const enhanceError = ( subject: string | WebdriverIO.Element | WdioElements, @@ -105,11 +103,11 @@ export const enhanceError = ( export const formatFailureMessage = ( subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, compareResults: CompareResult>[], - context: ExpectWebdriverIO.MatcherContext, - expectedValueArg2 = '', - { message = '', containing = false }): string => { + context: ExpectWebdriverIO.MatcherContext & { useNotInLabel?: boolean }, + expectedValueArgument2 = '', + { message = '', containing = false } = {}): string => { - const { isNot = false, expectation } = context + const { isNot = false, expectation, useNotInLabel = true } = context let { verb } = context subject = typeof subject === 'string' ? subject : getSelectors(subject) @@ -122,28 +120,30 @@ export const formatFailureMessage = ( if (verb) { verb += ' ' } + + const label = { + expected: isNot && useNotInLabel ? 'Expected [not]' : 'Expected', + received: isNot && useNotInLabel ? 'Received ' : 'Received' + } + const failedResults = compareResults.filter(({ result }) => result === isNot) let msg = '' for (const failResult of failedResults) { const { actual, expected, instance: instanceName } = failResult - let diffString = isNot && equals(actual, expected) - ? `${EXPECTED_LABEL}: ${printExpected(expected)}\n${RECEIVED_LABEL}: ${printReceived(actual)}` - : printDiffOrStringify(expected, actual, EXPECTED_LABEL, RECEIVED_LABEL, true) - - if (isNot) { - diffString = diffString - .replace(EXPECTED_LABEL, NOT_EXPECTED_LABEL) - .replace(RECEIVED_LABEL, RECEIVED_LABEL + ' '.repeat(NOT_SUFFIX.length)) - } + // Using `printDiffOrStringify()` with equals values output `Received: serializes to the same string`, so we need to tweak. + const diffString = equals(actual, expected) ?`\ +${label.expected}: ${printExpected(expected)} +${label.received}: ${printReceived(actual)}` + : printDiffOrStringify(expected, actual, label.expected, label.received, true) if (message) { message += '\n' } - if (expectedValueArg2) { - expectedValueArg2 = ` ${expectedValueArg2}` + if (expectedValueArgument2) { + expectedValueArgument2 = ` ${expectedValueArgument2}` } const mulitRemoteContext = context.isMultiRemote ? ` for remote "${instanceName}"` : '' @@ -158,8 +158,14 @@ export const formatFailureMessage = ( * * ``` */ - msg += `${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${expectedValueArg2}${contain}${mulitRemoteContext}\n\n${diffString}\n\n` + msg += `\ +${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${expectedValueArgument2}${contain}${mulitRemoteContext} + +${diffString} + +` } + return msg.trim() } diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index 29d457f59..d4bfb5bd3 100644 --- a/test/util/formatMessage.test.ts +++ b/test/util/formatMessage.test.ts @@ -176,7 +176,8 @@ describe('formatMessage', () => { }) describe(formatFailureMessage, () => { const subject = 'window' - const expectation = 'have title' + const expectation = 'title' + const verb = 'have' const expectedValueArgument2 = 'myProp' const baseResult: CompareResult = { @@ -189,7 +190,7 @@ describe('formatMessage', () => { const baseContext: ExpectWebdriverIO.MatcherContext = { isNot: false, expectation, - verb: '', + verb, isMultiRemote: false } @@ -200,9 +201,11 @@ describe('formatMessage', () => { const message = formatFailureMessage(subject, results, context, '', {}) - expect(message).toMatch('Expect window to have title') - const diffString = printDiffOrStringify('expected', 'actual', 'Expected', 'Received', true) - expect(message).toMatch(diffString) + expect(message).toEqual(`\ +Expect window to have title + +Expected: "expected" +Received: "actual"`) }) }) @@ -229,13 +232,16 @@ describe('formatMessage', () => { const message = formatFailureMessage(subject, results, context, '', {}) - expect(message).toContain('Expect window to have title for remote "browserA"') - const diffString1 = printDiffOrStringify('expected1', 'actual1', 'Expected', 'Received', true) - expect(message).toContain(diffString1) + expect(message).toEqual(`\ +Expect window to have title for remote "browserA" + +Expected: "expected1" +Received: "actual1" + +Expect window to have title for remote "browserB" - expect(message).toContain('Expect window to have title for remote "browserB"') - const diffString2 = printDiffOrStringify('expected2', 'actual2', 'Expected', 'Received', true) - expect(message).toContain(diffString2) +Expected: "expected2" +Received: "actual2"`) }) }) @@ -254,30 +260,51 @@ describe('formatMessage', () => { const message = formatFailureMessage(subject, results, context, '', {}) - expect(message).toMatch('Expect window not to have title') - const diffString = `Expected [not]: ${printExpected('actual')}\n` + - `Received : ${printReceived('actual')}` - expect(message).toMatch(diffString) + expect(message).toEqual(`\ +Expect window not to have title + +Expected [not]: "actual" +Received : "actual"`) }) test('should handle message', () => { const results = [baseResult] - const context = { ...baseContext, expectation: 'have property' } + const context = { ...baseContext, expectation: 'property' } const message = formatFailureMessage('my-element', results, context, expectedValueArgument2, { message: 'Custom Message', containing: false }) - expect(message).toMatch('Custom Message') - expect(message).toMatch('Expect my-element to have property myProp') - expect(message).not.toContain('containing') + expect(message).toEqual(`\ +Custom Message +Expect my-element to have property myProp + +Expected: "expected" +Received: "actual"`) }) test('should handle containing', () => { const results = [baseResult] - const context = { ...baseContext, expectation: 'have property' } + const context = { ...baseContext, expectation: 'property' } const message = formatFailureMessage('my-element', results, context, expectedValueArgument2, { message: '', containing: true }) - expect(message).toMatch('Expect my-element to have property myProp containing') + expect(message).toEqual(`\ +Expect my-element to have property myProp containing + +Expected: "expected" +Received: "actual"`) + }) + + test('should handle no verb', () => { + const results = [baseResult] + const context = { ...baseContext, expectation: 'exist', verb: '' } + + const message = formatFailureMessage('my-element', results, context) + + expect(message).toEqual(`\ +Expect my-element to exist + +Expected: "expected" +Received: "actual"`) }) }) }) From 20eefacb1cd88ed47a112cd6a5196d3848ec1317 Mon Sep 17 00:00:00 2001 From: dprevost-perso Date: Mon, 5 Jan 2026 09:17:44 -0500 Subject: [PATCH 30/30] fix: Stop altering arg variable + more robust and clear concatenation --- src/util/formatMessage.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index c59a54458..dd871238b 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -42,6 +42,8 @@ export const getSelectors = (el: WebdriverIO.Element | WdioElements) => { const not = (isNot: boolean): string => `${isNot ? 'not ' : ''}` +const startSpace = (word = ''): string | undefined => word ? ` ${word}` : word + export const enhanceError = ( subject: string | WebdriverIO.Element | WdioElements, expected: unknown, @@ -107,19 +109,12 @@ export const formatFailureMessage = ( expectedValueArgument2 = '', { message = '', containing = false } = {}): string => { - const { isNot = false, expectation, useNotInLabel = true } = context - let { verb } = context + const { isNot = false, expectation, useNotInLabel = true, verb } = context subject = typeof subject === 'string' ? subject : getSelectors(subject) - let contain = '' - if (containing) { - contain = ' containing' - } - - if (verb) { - verb += ' ' - } + const contain = containing ? 'containing' : '' + const customMessage = message ? `${message}\n` : '' const label = { expected: isNot && useNotInLabel ? 'Expected [not]' : 'Expected', @@ -138,19 +133,12 @@ ${label.expected}: ${printExpected(expected)} ${label.received}: ${printReceived(actual)}` : printDiffOrStringify(expected, actual, label.expected, label.received, true) - if (message) { - message += '\n' - } - - if (expectedValueArgument2) { - expectedValueArgument2 = ` ${expectedValueArgument2}` - } - - const mulitRemoteContext = context.isMultiRemote ? ` for remote "${instanceName}"` : '' + const mulitRemoteContext = context.isMultiRemote ? `for remote "${instanceName}"` : '' /** - * Example of below message (multi-remote + isNot case): + * Example of below message (custom message + multi-remote + isNot case): * ``` + * My custom error message * Expect window not to have title for remote "browserA" * * Expected not: "some Title text" @@ -159,7 +147,7 @@ ${label.received}: ${printReceived(actual)}` * ``` */ msg += `\ -${message}Expect ${subject} ${not(isNot)}to ${verb}${expectation}${expectedValueArgument2}${contain}${mulitRemoteContext} +${customMessage}Expect ${subject} ${not(isNot)}to${startSpace(verb)}${startSpace(expectation)}${startSpace(expectedValueArgument2)}${startSpace(contain)}${startSpace(mulitRemoteContext)} ${diffString}