diff --git a/docs/MultiRemote.md b/docs/MultiRemote.md new file mode 100644 index 000000000..a66fa76f9 --- /dev/null +++ b/docs/MultiRemote.md @@ -0,0 +1,155 @@ +# MultiRemote Support (Alpha) + +Multi-remote support is in active development. + +## Usage + +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 +import { multiremotebrowser as multiRemoteBrowser } from '@wdio/globals' +... +await expect(multiRemoteBrowser).toHaveTitle('...') +``` +Note: `multiRemoteBrowser` is used in examples pending a planned rename. + + +Assuming the following WebdriverIO multi-remote configuration: +```ts +export const config: WebdriverIO.MultiremoteConfig = { + ... + capabilities: { + myChromeBrowser: { + capabilities: { + browserName: 'chrome', + 'goog:chromeOptions': { args: ['--headless'] } + } + }, + myFirefoxBrowser: { + capabilities: { + browserName: 'firefox', + 'moz:firefoxOptions': { args: ['-headless'] } + } + } + }, + ... +} +``` + +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 + 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 thrown errors as failures, and will only throw if all remotes fail. +- SoftAssertions, snapshot services and network matchers might come after. + +## Alternatives + +Since multi-remote instances are standard browsers, you can also assert by iterating over the instance list. + +### Parameterized Tests +Using the parameterized feature of your test framework, you can iterate over the multi-remote instances. + +Mocha Parameterized Example +```ts + describe('Multiremote test', async () => { + multiRemoteBrowser.instances.forEach(function (instance) { + describe(`Test ${instance}`, 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("My Site Title") + }) + }) + }) + }) +``` +### 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('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 4c18dd7f8..4e76f467d 100644 --- a/src/matchers/browser/toHaveTitle.ts +++ b/src/matchers/browser/toHaveTitle.ts @@ -1,13 +1,31 @@ -import { waitUntil, enhanceError, compareText } from '../../utils.js' +import { compareText, waitUntilResultSucceed } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' +import type { MaybeArray } from '../../util/multiRemoteUtil.js' +import { mapExpectedValueWithInstances } from '../../util/multiRemoteUtil.js' +import { formatFailureMessage } 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 + const context = { expectation, verb, isNot, isMultiRemote: browser.isMultiremote } await options.beforeAssertion?.({ matcherName: 'toHaveTitle', @@ -15,25 +33,34 @@ export async function toHaveTitle( options, }) - let actual - const pass = await waitUntil(async () => { - actual = await browser.getTitle() + const browsersWithExpected = mapExpectedValueWithInstances(browser, expectedValue) + + const conditions = Object.entries(browsersWithExpected).map(([instanceName, { browser, expectedValue: expected }]) => async () => { + const actual = await browser.getTitle() - return compareText(actual, expectedValue, options).result - }, isNot, options) + const result = compareText(actual, expected, options) + result.instance = instanceName + return result + }) + + const conditionsResults = await waitUntilResultSucceed( + conditions, + isNot, + options, + ) - const message = enhanceError('window', expectedValue, actual, this, verb, expectation, '', options) - const result: ExpectWebdriverIO.AssertionResult = { - pass, - message: () => message + const message = formatFailureMessage('window', conditionsResults.results, context, '', options) + const assertionResult: ExpectWebdriverIO.AssertionResult = { + pass: conditionsResults.pass, + message: () => message, } await options.afterAssertion?.({ matcherName: 'toHaveTitle', expectedValue, options, - result + result: assertionResult, }) - return result + return assertionResult } diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index d5bf2b665..dd871238b 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' @@ -39,18 +40,19 @@ 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 ' : ''}` + +const startSpace = (word = ''): string | undefined => word ? ` ${word}` : word 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 => { @@ -85,10 +87,76 @@ 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 } +/** + * 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>[], + context: ExpectWebdriverIO.MatcherContext & { useNotInLabel?: boolean }, + expectedValueArgument2 = '', + { message = '', containing = false } = {}): string => { + + const { isNot = false, expectation, useNotInLabel = true, verb } = context + + subject = typeof subject === 'string' ? subject : getSelectors(subject) + + const contain = containing ? 'containing' : '' + const customMessage = message ? `${message}\n` : '' + + 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 + + // 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) + + const mulitRemoteContext = context.isMultiRemote ? `for remote "${instanceName}"` : '' + + /** + * 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" + * Received: "some Wrong Title text" + * + * ``` + */ + msg += `\ +${customMessage}Expect ${subject} ${not(isNot)}to${startSpace(verb)}${startSpace(expectation)}${startSpace(expectedValueArgument2)}${startSpace(contain)}${startSpace(mulitRemoteContext)} + +${diffString} + +` + } + + return msg.trim() +} + 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..ca6945a4f --- /dev/null +++ b/src/util/multiRemoteUtil.ts @@ -0,0 +1,36 @@ +import type { Browser } from 'webdriverio' + +export const toArray = (value: T | T[] | MaybeArray): T[] => (Array.isArray(value) ? value : [value]) + +export type MaybeArray = T | T[] + +export const isMultiRemote = (browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser): browser is WebdriverIO.MultiRemoteBrowser => { + return (browser as WebdriverIO.MultiRemoteBrowser).isMultiremote === true +} + +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) { + 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 + + const browsersWithExpected = browsers.instances.reduce((acc: BrowserWithExpected, instance, index) => { + const browser = browsers.getInstance(instance) + const expectedValue: T = Array.isArray(expectedValues) ? expectedValues[index] : expectedValues + acc[instance] = { browser, expectedValue } + return acc + }, {}) + return browsersWithExpected + } + + // TODO multi-remote support: using default could clash if someone use name default, to review later + return { default: { browser: browsers, expectedValue: expectedValues as T } } +} diff --git a/src/utils.ts b/src/utils.ts index 3987241ab..b0ac4d0f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,13 +1,21 @@ 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' +import { toArray } from './util/multiRemoteUtil.js' + +export type CompareResult = { + value: A // actual but sometimes modified (e.g. trimmed, lowercased, etc) + actual: A // actual value as is + expected: E + 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)) @@ -31,6 +39,80 @@ function isStringContainingMatcher(expected: unknown): expected is WdioAsymmetri return isAsymmetricMatcher(expected) && ['StringContaining', 'StringNotContaining'].includes(expected.toString()) } +/** + * 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(s) that should return compare result(s) when resolved + * @param isNot https://jestjs.io/docs/expect#thisisnot + * @param options wait, interval, etc + */ +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 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 + 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 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) => { + pendingResult.results = toArray(await pendingResult.condition()) + }), + ) + + 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 expectation to succeed * @param condition function @@ -97,7 +179,7 @@ async function executeCommandBe( const result = await executeCommand.call( this, el, - async (element ) => ({ result: await command(element as WebdriverIO.Element) }), + async (element) => ({ result: await command(element as WebdriverIO.Element) }), options ) el = result.el as WebdriverIO.Element @@ -151,76 +233,86 @@ export const compareText = ( atIndex, replace, }: ExpectWebdriverIO.StringOptions -) => { +): 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, - } + 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())) as WdioAsymmetricMatcher + 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()) + ) satisfies Partial> as WdioAsymmetricMatcher } } - if (isAsymmetricMatcher(expected)) { - const result = expected.asymmetricMatch(actual) + if (isAsymmetricMatcher(expectedValue)) { + const result = expectedValue.asymmetricMatch(value) return { - value: actual, - result + ...compareResult, + value, + result, } } - if (expected instanceof RegExp) { + if (expectedValue instanceof RegExp) { return { - value: actual, - result: !!actual.match(expected), + ...compareResult, + value, + result: !!value.match(expectedValue), } } if (containing) { return { - value: actual, - result: actual.includes(expected), + ...compareResult, + value, + result: value.includes(expectedValue), } } if (atStart) { return { - value: actual, - result: actual.startsWith(expected), + ...compareResult, + value: value, + result: value.startsWith(expectedValue), } } if (atEnd) { return { - value: actual, - result: actual.endsWith(expected), + ...compareResult, + value, + result: value.endsWith(expectedValue), } } if (atIndex) { return { - value: actual, - result: actual.substring(atIndex, actual.length).startsWith(expected), + ...compareResult, + value, + result: value.substring(atIndex, value.length).startsWith(expectedValue), } } return { - value: actual, - result: actual === expected, + ...compareResult, + value, + result: value === expectedValue, } } @@ -349,7 +441,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 +459,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 @@ -383,7 +469,7 @@ function aliasFn( export { aliasFn, compareNumbers, enhanceError, executeCommand, - executeCommandBe, numberError, waitUntil, wrapExpectedWithArray + executeCommandBe, numberError, waitUntil, waitUntilResultSucceed, wrapExpectedWithArray } function replaceActual( diff --git a/test-types/mocha/types-mocha.test.ts b/test-types/mocha/types-mocha.test.ts index 877ecdddf..951b1cda4 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 () => { @@ -121,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') diff --git a/test/matchers/browser/toHaveTitle.test.ts b/test/matchers/browser/toHaveTitle.test.ts new file mode 100644 index 000000000..c60d068ca --- /dev/null +++ b/test/matchers/browser/toHaveTitle.test.ts @@ -0,0 +1,697 @@ +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() + +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(''), + }, + multiremotebrowser: { + isMultiremote: true, + instances: ['browserA'], + getInstance: (name: string) => { + const instance = multiRemoteBrowserInstances[name] + if (!instance) { + throw new Error(`No such instance: ${name}`) + } + return instance + } + } +})) + +describe('toHaveTitle', async () => { + 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) + }) + + 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.instances = ['browserA'] + browserA.getTitle = vi.fn().mockResolvedValue(goodTitle) + browserB.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 () => { + 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"` + ) + }) + }) + + describe('given multiple remote browsers', async () => { + beforeEach(async () => { + multiremotebrowser.instances = ['browserA', 'browserB'] + browserA.getTitle = vi.fn().mockResolvedValue(goodTitle) + browserB.getTitle = vi.fn().mockResolvedValue(goodTitle) + }) + + describe('given one expected value', async () => { + + 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 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 multiple expected values', async () => { + const goodTitle2 = `${goodTitle} 2` + 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(multiremotebrowser, expectedValues) + + expect(result.pass).toBe(true) + }) + + 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 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({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveTitle', + expectedValue: expectedValues, + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result, + }) + }) + }) + }) + }) + }) + }) + + 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 () => { + 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(browser, negatedExpectedTitle) + + expect(result.pass).toBe(true) + }) + + 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 not to have title + +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(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 not to have title + +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 () => { + 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) + + 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"` + ) + }) + }) + + describe('given multiple remote browsers', async () => { + + 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, negatedExpectedTitle) + + expect(result.pass).toBe(true) + }) + + test('when failure for one browser', 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"` + ) + }) + + 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, + }) + }) + + 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, + }) + }) + }) + }) + + 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 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 2 not expected to be" +Received : "some Title text 2 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, + 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 () => { + 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, + }) + }) + }) + }) + }) + }) + }) +}) diff --git a/test/matchers/browserMatchers.test.ts b/test/matchers/browserMatchers.test.ts index 52c3a4c00..ca5258602 100644 --- a/test/matchers/browserMatchers.test.ts +++ b/test/matchers/browserMatchers.test.ts @@ -6,7 +6,7 @@ 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 ' @@ -145,4 +145,3 @@ describe('browser matchers', () => { }) }) }) - diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index 0bfcdd287..d4bfb5bd3 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,138 @@ describe('formatMessage', () => { expect(numberError({ gte: 2, lte: 1 })).toBe('>= 2 && <= 1') }) }) + describe(formatFailureMessage, () => { + const subject = 'window' + const expectation = 'title' + const verb = 'have' + 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).toEqual(`\ +Expect window to have title + +Expected: "expected" +Received: "actual"`) + }) + }) + + 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).toEqual(`\ +Expect window to have title for remote "browserA" + +Expected: "expected1" +Received: "actual1" + +Expect window to have title for remote "browserB" + +Expected: "expected2" +Received: "actual2"`) + }) + }) + + 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).toEqual(`\ +Expect window not to have title + +Expected [not]: "actual" +Received : "actual"`) + }) + + test('should handle message', () => { + const results = [baseResult] + const context = { ...baseContext, expectation: 'property' } + + const message = formatFailureMessage('my-element', results, context, expectedValueArgument2, { message: 'Custom Message', containing: false }) + + 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: 'property' } + + const message = formatFailureMessage('my-element', results, context, expectedValueArgument2, { message: '', containing: true }) + + 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"`) + }) + }) + }) }) diff --git a/test/util/multiRemoteUtil.test.ts b/test/util/multiRemoteUtil.test.ts new file mode 100644 index 000000000..eead1f453 --- /dev/null +++ b/test/util/multiRemoteUtil.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { toArray, isMultiRemote, mapExpectedValueWithInstances } 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(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(mapExpectedValueWithInstances, () => { + it('should return default instance for single browser', () => { + const browser = { isMultiremote: false } satisfies Partial as WebdriverIO.Browser + const expected = 'expected' + const result = mapExpectedValueWithInstances(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 = mapExpectedValueWithInstances(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 = mapExpectedValueWithInstances(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(() => 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 444a8e61f..0a33d5687 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, waitUntilResultSucceed } from '../src/utils' describe('utils', () => { describe('compareText', () => { @@ -158,4 +159,490 @@ describe('utils', () => { expect(compareObject([{ 'foo': 'bar' }], { 'foo': 'bar' }).result).toBe(false) }) }) + + 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 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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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(waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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(waitUntilResultSucceed(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 waitUntilResultSucceed(condition, isNot, { wait: 1000, interval: 50 }) + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }], + }) + expect(attempts).toBe(3) + }) + + }) + }) + + 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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 attempts = 0 + const conditions = async () => { + attempts++ + return attempts >= 3 ? trueConditions() : falseConditions() + } + + const result = await waitUntilResultSucceed(conditions, isNot, { wait: 1000, interval: 50 }) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...trueCompareResult, pass : true }], + }) + expect(attempts).toBeGreaterThanOrEqual(3) + }) + + test('should return false when condition is not met within wait time', async () => { + const result = await waitUntilResultSucceed(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(waitUntilResultSucceed(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() + } + + const result = await waitUntilResultSucceed(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 waitUntilResultSucceed(trueConditions) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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(waitUntilResultSucceed(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 waitUntilResultSucceed(conditions, isNot, { wait: 1000, interval: 50 }) + expect(result).toEqual({ + pass: false, + results: [{ ...trueCompareResult, pass : false }, { ...trueCompareResult, pass : false }], + }) + expect(attempts).toBe(3) + }) + + }) + }) + + 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 waitUntilResultSucceed(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 waitUntilResultSucceed(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() + } + let attempts2 = 0 + const condition2 = async () => { + attempts2++ + return attempts2 >= 3 ? trueCondition() : falseCondition() + } + + const result = await waitUntilResultSucceed([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 waitUntilResultSucceed(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(waitUntilResultSucceed(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 waitUntilResultSucceed([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 waitUntilResultSucceed(trueConditions) + + expect(result).toEqual({ + pass: true, + results: [{ ...trueCompareResult, pass : true }, { ...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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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 waitUntilResultSucceed(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(waitUntilResultSucceed(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 waitUntilResultSucceed([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) + }) + + }) + }) + }) }) diff --git a/types/expect-webdriverio.d.ts b/types/expect-webdriverio.d.ts index 74963f437..ff1e3a6b9 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 @@ -21,10 +21,12 @@ 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 - (this: Context, actual: any, ...expected: Array): ExpectLibExpectationResult; + (this: Context, actual: any, ...expected: Array): ExpectLibExpectationResult } /** @@ -61,6 +63,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 @@ -70,7 +73,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. */ @@ -81,9 +84,21 @@ interface WdioBrowserMatchers<_R, ActualT>{ toHaveUrl: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> /** - * `WebdriverIO.Browser` -> `getTitle` + * `WebdriverIO.Browser`, `WebdriverIO.MultiRemoteBrowser` -> `getTitle` */ - toHaveTitle: FnWhenBrowser, options?: ExpectWebdriverIO.StringOptions) => Promise> + toHaveTitle: FnWhenBrowserOrMultiRemote< + ActualT, + // Browser + ( + title: string | RegExp | ExpectWebdriverIO.PartialMatcher, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise, + // MultiRemoteBrowser + ( + url: MaybeArray>, + options?: ExpectWebdriverIO.StringOptions, + ) => Promise + > /** * `WebdriverIO.Browser` -> `execute` @@ -453,7 +468,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 +485,16 @@ 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 + isMultiRemote?: boolean + } + /** * The below block are overloaded types from the expect library. * They are required to show "everything" under the `ExpectWebdriverIO` namespace. @@ -518,12 +543,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 @@ -729,7 +754,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?: