diff --git a/docs/API.md b/docs/API.md index f6b397c5f..a83ed0e13 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,6 +2,8 @@ When you're writing tests, you often need to check that values meet certain conditions. `expect` gives you access to a number of "matchers" that let you validate different things on the `browser`, an `element` or `mock` object. +**Note**: Multi-remote is not yet supported. Any working scenario is coincidental and may break or change without notice until fully supported. + ## Soft Assertions Soft assertions allow you to continue test execution even when an assertion fails. This is useful when you want to check multiple conditions in a test and collect all failures rather than stopping at the first failure. Failures are collected and reported at the end of the test. @@ -726,6 +728,30 @@ await expect(listItems).toBeElementsArrayOfSize({ gte: 5 }) await expect(listItems).toBeElementsArrayOfSize({ gte: 5, lte: 5 }) ``` +### Multiple Elements Support + +Matchers starting with `toBe` support arrays of elements returned from `$$()`: +- **Standard Behavior:** Every element must pass. One failure fails the assertion. +- **Using `.not`:** Every element must *not* meet the matcher condition. One match fails the assertion. +- **Empty Arrays:** Empty element arrays will fail the assertion. + - *Note:* Only the `toExist`, `toBeExisting`, and `toBePresent` matchers succeed when using `.not` on an empty element array. + +#### Usage + +```ts +// Awaited selector syntax +await expect(await $$('#someElements')).toBeDisplayed() + +// Non-awaited +await expect($$('#someElements')).toBeDisplayed() + +// Works with filtered arrays +await expect($$('#someElements').filter((t) => t.isExisting())).toBeDisplayed() + +// Using the .not modifier (Asserts NO elements are displayed) +await expect($$('#someElements')).not.toBeDisplayed() +``` + ## Network Matchers ### toBeRequested diff --git a/playgrounds/mocha/test/specs/wdio-matchers.test.ts b/playgrounds/mocha/test/specs/wdio-matchers.test.ts index 56d96e0c4..3368d7c37 100644 --- a/playgrounds/mocha/test/specs/wdio-matchers.test.ts +++ b/playgrounds/mocha/test/specs/wdio-matchers.test.ts @@ -25,27 +25,58 @@ describe('WebdriverIO Custom Matchers', () => { describe('Element existence matchers', () => { it('should verify element exists', async () => { - const searchButton = await $('.DocSearch-Button') + const searchButton = $('.DocSearch-Button') + + await expect(searchButton).toExist() + await expect(await searchButton).toExist() + await expect(await searchButton).toBeExisting() + }) + + it('should verify all elements are existing', async () => { + const searchButton = $$('.DocSearch-Button') + await expect(searchButton).toExist() - await expect(searchButton).toBeExisting() + await expect(await searchButton).toExist() + await expect(await searchButton).toBeExisting() }) it('should verify element does not exist', async () => { const nonExistent = await $('.non-existent-element') + await expect(nonExistent).not.toExist() + await expect(await nonExistent).not.toExist() }) + + it('should verify elements do not exist', async () => { + const nonExistent = $$('.non-existent-elements') + + await expect(nonExistent).not.toExist() + await expect(await nonExistent).not.toExist() + }) + }) describe('Element visibility matchers', () => { it('should verify element is displayed', async () => { - const nav = await $('nav') + const nav = $('nav') + await expect(nav).toBeDisplayed() + await expect(await nav).toBeDisplayed() }) it('should verify element is displayed in viewport', async () => { const searchButton = await $('.DocSearch-Button') + await expect(searchButton).toBeDisplayedInViewport() }) + + it('should verify elements are displayed', async () => { + const nav = $$('nav') + + await expect(nav).toBeDisplayed() + await expect(await nav).toBeDisplayed() + await expect(await nav.filter(n => n.isExisting())).toBeDisplayedInViewport() + }) }) describe('Element state matchers', () => { diff --git a/playgrounds/mocha/visual-snapshot/baseline/desktop_chrome/fullPage-chrome-mocha-756x556.png b/playgrounds/mocha/visual-snapshot/baseline/desktop_chrome/fullPage-chrome-mocha-756x556.png index 566166a20..c3ea10033 100644 Binary files a/playgrounds/mocha/visual-snapshot/baseline/desktop_chrome/fullPage-chrome-mocha-756x556.png and b/playgrounds/mocha/visual-snapshot/baseline/desktop_chrome/fullPage-chrome-mocha-756x556.png differ diff --git a/src/matchers/element/toBeClickable.ts b/src/matchers/element/toBeClickable.ts index 5e9f95bad..607409165 100644 --- a/src/matchers/element/toBeClickable.ts +++ b/src/matchers/element/toBeClickable.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toBeClickable( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'clickable' diff --git a/src/matchers/element/toBeDisabled.ts b/src/matchers/element/toBeDisabled.ts index 471304675..429fafdd1 100644 --- a/src/matchers/element/toBeDisabled.ts +++ b/src/matchers/element/toBeDisabled.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toBeDisabled( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'disabled' diff --git a/src/matchers/element/toBeDisplayed.ts b/src/matchers/element/toBeDisplayed.ts index ec06f1c24..48f5a8131 100644 --- a/src/matchers/element/toBeDisplayed.ts +++ b/src/matchers/element/toBeDisplayed.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' import { DEFAULT_OPTIONS_TO_BE_DISPLAYED } from '../../constants.js' export async function toBeDisplayed( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.ToBeDisplayedOptions = DEFAULT_OPTIONS_TO_BE_DISPLAYED, ) { this.expectation = this.expectation || 'displayed' diff --git a/src/matchers/element/toBeDisplayedInViewport.ts b/src/matchers/element/toBeDisplayedInViewport.ts index 32c7ae8df..a94607f15 100644 --- a/src/matchers/element/toBeDisplayedInViewport.ts +++ b/src/matchers/element/toBeDisplayedInViewport.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toBeDisplayedInViewport( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'displayed in viewport' diff --git a/src/matchers/element/toBeEnabled.ts b/src/matchers/element/toBeEnabled.ts index 0a3a612a4..cccdf8bc4 100644 --- a/src/matchers/element/toBeEnabled.ts +++ b/src/matchers/element/toBeEnabled.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toBeEnabled( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'enabled' diff --git a/src/matchers/element/toBeExisting.ts b/src/matchers/element/toBeExisting.ts index 8f48d7dee..785196b27 100644 --- a/src/matchers/element/toBeExisting.ts +++ b/src/matchers/element/toBeExisting.ts @@ -1,13 +1,14 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toExist( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'exist' this.verb = this.verb || '' + this.allowEmptyElements = true await options.beforeAssertion?.({ matcherName: 'toExist', // TODO use this.matcher = this.matcher || toExist in v6.0.0 to fix matcherName issue with toBeExisting and toBePresent @@ -25,13 +26,13 @@ export async function toExist( return result } -export function toBeExisting(el: WdioElementMaybePromise, options?: ExpectWebdriverIO.CommandOptions) { +export function toBeExisting(el: WdioElementOrArrayMaybePromise, options?: ExpectWebdriverIO.CommandOptions) { this.expectation = 'existing' this.verb = 'be' return toExist.call(this, el, options) } -export function toBePresent(el: WdioElementMaybePromise, options?: ExpectWebdriverIO.CommandOptions) { +export function toBePresent(el: WdioElementOrArrayMaybePromise, options?: ExpectWebdriverIO.CommandOptions) { this.expectation = 'present' this.verb = 'be' diff --git a/src/matchers/element/toBeFocused.ts b/src/matchers/element/toBeFocused.ts index 7fe8d19bb..c81c06fa0 100644 --- a/src/matchers/element/toBeFocused.ts +++ b/src/matchers/element/toBeFocused.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toBeFocused( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'focused' diff --git a/src/matchers/element/toBeSelected.ts b/src/matchers/element/toBeSelected.ts index 4a7ac45d3..85b4b6da3 100644 --- a/src/matchers/element/toBeSelected.ts +++ b/src/matchers/element/toBeSelected.ts @@ -1,9 +1,9 @@ import { executeCommandBe } from '../../utils.js' import { DEFAULT_OPTIONS } from '../../constants.js' -import type { WdioElementMaybePromise } from '../../types.js' +import type { WdioElementOrArrayMaybePromise } from '../../types.js' export async function toBeSelected( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS ) { this.expectation = this.expectation || 'selected' @@ -24,7 +24,7 @@ export async function toBeSelected( return result } -export async function toBeChecked (el: WdioElementMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS) { +export async function toBeChecked (el: WdioElementOrArrayMaybePromise, options: ExpectWebdriverIO.CommandOptions = DEFAULT_OPTIONS) { this.expectation = 'checked' await options.beforeAssertion?.({ diff --git a/src/matchers/element/toHaveText.ts b/src/matchers/element/toHaveText.ts index c3efce5a0..19e2350fa 100644 --- a/src/matchers/element/toHaveText.ts +++ b/src/matchers/element/toHaveText.ts @@ -40,6 +40,8 @@ export async function toHaveText( const commandResult = await executeCommandWithStrategy( { unresolvedElements: received, singleElementCompare: (element, _index) => compareElement(element, expectedValue, options), + isNot, + strategy: 'LegacyMultipleElements', }) subject = commandResult.subject actualText = commandResult.actual diff --git a/src/util/executeCommand.ts b/src/util/executeCommand.ts index 9c268c547..b61318af5 100644 --- a/src/util/executeCommand.ts +++ b/src/util/executeCommand.ts @@ -25,35 +25,64 @@ export async function executeCommand( } } -export type StrategyResult = { result: boolean; value: T } +export type StrategyType = 'LegacyMultipleElements' | 'NewMultipleElements' +export type CompareResult = { result: boolean; value: T } +export type StrategyResult = { + subject: WebdriverIO.Element | WebdriverIO.ElementArray | WebdriverIO.Element[] | unknown; + success: boolean; + actual: MaybeArray | undefined; +} /** - * Fetch element(s) and execute the compare strategy for each element, returning the results. - * if there is no element or empty element array, it will return a failure result. - * if there is a single element, it will return the result of the compare strategy for that element. - * if there is an array of elements, it will return the result of the compare strategy for each element. - * - * For a successful result, all elements must pass the compare strategy. - * For a failure result, at least one element must fail the compare strategy. - * - * For `.not` assertions, since success need to be inverted for successful result, so if at least one element fails the compare strategy, it will return a successful result. - * The above can be is confusing, yeilding ambigious results, so behavior in this case need to be reviewed and improved in the future. + * Fetch element(s) and route them to the appropriate comparison strategy. + * Acts as a router to dispatch the elements to either the legacy or new comparison strategy. * * @param unresolvedElements awaited or non-awaited element(s) to be resolved and compared * @param singleElementCompare compare a single element with expected value(s) + * @param isNot indicates if the assertion is inverted (e.g., using `.not`) + * @param strategy the strategy type to use (defaults to 'NewMultipleElements') + * @param configuration configuration options for the strategy * @returns An object containing the subject, success status, actual values, and results of the comparison */ export async function executeCommandWithStrategy( { unresolvedElements, - singleElementCompare: singleElementCompare, + singleElementCompare, + isNot, + strategy = 'NewMultipleElements', + configuration = { allowEmptyElements: false } } :{ unresolvedElements: WdioElementOrArrayMaybePromise | unknown - singleElementCompare: (awaitedElement: WebdriverIO.Element, index?: number) => Promise> + singleElementCompare: (awaitedElement: WebdriverIO.Element, index?: number) => Promise> + isNot: boolean + strategy?: StrategyType, + configuration?: { allowEmptyElements?: boolean } } -): Promise<{ subject: WebdriverIO.Element| WebdriverIO.ElementArray | WebdriverIO.Element[] | unknown; - success: boolean; - actual: MaybeArray | undefined; -}> { +): Promise> { + if (strategy === 'LegacyMultipleElements') { + return legacyMultipleElementResultsStrategy(unresolvedElements, singleElementCompare, isNot) + } + + // Default new strategy for single & multiple element results, which is more consistent and less ambigious than the legacy strategy. + return multipleElementResultsStrategy(unresolvedElements, singleElementCompare, isNot, configuration) +} + +/** + * Legacy multiple element comparison strategy. + * + * Previous multi-element compare mechanism that started with `toHaveText` matcher. + * Flaws: + * - If there is no element or an empty array, it returns success with `.not` even though there are no elements' value to compare against. + * - When asserting with `.not` to not have a given text, if at least one element does not have the text, it returns success even though other elements may have the text. + * + * @deprecated The above behavior can be confusing, yielding ambiguous results. + * Kept for backward compatibility, to not be breaking but still be able to rollout the below new strategy. + */ +export const legacyMultipleElementResultsStrategy = async ( + unresolvedElements: WdioElementOrArrayMaybePromise | unknown, + singleElementCompare: (awaitedElement: WebdriverIO.Element, index?: number) => Promise>, + _isNot?: boolean + +): Promise> => { const { selector, other, isEmptyElements } = await awaitElementOrArray(unresolvedElements) const subject = selector ?? other if (!selector || isEmptyElements) { @@ -65,11 +94,11 @@ export async function executeCommandWithStrategy( { } if (isElement(selector)) { - const strategyResult = await singleElementCompare(selector) + const compareResult = await singleElementCompare(selector) return { subject, - success: strategyResult.result, - actual: strategyResult.value, + success: compareResult.result, + actual: compareResult.value, } } @@ -81,7 +110,7 @@ export async function executeCommandWithStrategy( { if (firstRejection) { throw firstRejection.reason } - const results = settled.map((r) => (r as PromiseFulfilledResult>).value) + const results = settled.map((r) => (r as PromiseFulfilledResult>).value) return { subject, @@ -90,3 +119,63 @@ export async function executeCommandWithStrategy( { } } +/** + * Modern multiple element comparison strategy. + * + * Handles element arrays consistently: + * - By default, if there is no element or an empty array, it returns a failure result. + * - For a standard successful result, all elements must pass the compare strategy. + * - For `.not` assertions, it ensures that all elements fail the compare strategy to pass. + * + * In rare cases (e.g., matchers using `isExisting`), the strategy can be configured via + * `allowEmptyElements` to let an empty element set pass the assertion instead of failing. + */ +export const multipleElementResultsStrategy = async ( + unresolvedElements: WdioElementOrArrayMaybePromise | unknown, + singleElementCompare: (awaitedElement: WebdriverIO.Element, index?: number) => Promise>, + isNot: boolean, + { allowEmptyElements = false } = {} +): Promise> => { + const { selector, other, isEmptyElements } = await awaitElementOrArray(unresolvedElements) + const subject = selector ?? other + if (!selector || isEmptyElements) { + return { + subject: subject, + success: isNot ? !allowEmptyElements : false, + actual: undefined, + } + } + + if (isElement(selector)) { + const compareResult = await singleElementCompare(selector) + return { + subject, + success: compareResult.result, + actual: compareResult.value, + } + } + + const settled = await Promise.allSettled( + Array.from(selector).map((el: WebdriverIO.Element, index: number) => singleElementCompare(el, index)) + ) + + // Re-throw the first rejection so waitUntil surfaces the real error message + const firstRejection = settled.find((r): r is PromiseRejectedResult => r.status === 'rejected') + if (firstRejection) { + throw firstRejection.reason + } + const results = settled.map((r) => (r as PromiseFulfilledResult>).value) + + const isNotEmpty = results.length > 0 + + // Success if all elements pass the compare strategy, or when using `.not`, if all elements fail the compare strategy. + // If there are no elements, it is considered a failure in both case with and without `.not`, as there are no elements to compare against. + return { + subject, + success: isNot ? !(isNotEmpty && isAllFalse(results)) : (isNotEmpty && isAllTrue(results)), + actual: results.map(({ value }) => value) + } +} + +const isAllTrue = (results: CompareResult[]): boolean => results.every((res) => res.result === true) +const isAllFalse = (results: CompareResult[]): boolean => results.every((res) => res.result === false) diff --git a/src/util/formatMessage.ts b/src/util/formatMessage.ts index 1076f357e..0d4cf717c 100644 --- a/src/util/formatMessage.ts +++ b/src/util/formatMessage.ts @@ -1,13 +1,15 @@ import { printDiffOrStringify, printExpected, printReceived } from 'jest-matcher-utils' import { equals } from '../jasmineUtils.js' import type { WdioElements } from '../types.js' -import { isArrayOfElement, isElementArray, isElementOrArrayLike } from './elementsUtil.js' +import { isArrayOfElement, isElementArray, isElementArrayLike, isElementOrArrayLike } from './elementsUtil.js' import { numberMatcherTester } from './numberOptionsUtil.js' import { toJsonString } from './stringUtil.js' // TODO one day use a real asymmetric matcher for number options instead of this custom equality tester const CUSTOM_EQUALITY_TESTER = [numberMatcherTester] +export const isDefined = (value: T): value is NonNullable => value !== null && value !== undefined + export const getSelector = (el: WebdriverIO.Element | WebdriverIO.ElementArray) => { let result = typeof el.selector === 'string' ? el.selector : '' if (Array.isArray(el) && (el as WebdriverIO.ElementArray).props.length > 0) { @@ -38,11 +40,11 @@ export const getSelectors = (el: WebdriverIO.Element | WdioElements): string => } while (!!parent && typeof parent === 'object' && 'selector' in parent) { - const selector = getSelector(parent as WebdriverIO.Element) - const index = parent.index ? `[${parent.index}]` : '' - selectors.push(`${parent.index ? '$' : ''}$(\`${selector}\`)${index}`) + const selector = getSelector(parent) + const index = isDefined(parent.index) ? `[${parent.index}]` : '' + selectors.push(`${isDefined(parent.index) ? '$' : ''}$(\`${selector}\`)${index}`) - parent = (parent as WebdriverIO.Element).parent + parent = parent.parent } return selectors.reverse().join('.') @@ -104,14 +106,32 @@ ${diffString}` return msg } +const toArray = (value: T | T[] | undefined): T[] => value === undefined ? [] : Array.isArray(value) ? value : [value] + export const enhanceErrorBe = ( - subject: string | WebdriverIO.Element | WebdriverIO.ElementArray, + subject: WebdriverIO.Element | WdioElements | unknown, + results: boolean[] | boolean | undefined, context: { isNot: boolean, verb: string, expectation: string }, options: ExpectWebdriverIO.CommandOptions ) => { const { isNot, verb, expectation } = context - const expected = `${not(isNot)}${expectation}` - const actual = `${not(!isNot)}${expectation}` + let expected + let actual + + const expectedValue = `${not(isNot)}${expectation}` + const actualValue = `${not(!isNot)}${expectation}` + + if (isElementArrayLike(subject)) { + expected = subject.length === 0? 'at least one result' : Array(subject.length).fill(expectedValue) + actual = toArray(results).map(result => isSuccess(isNot, result) ? `${not(isNot)}${expectation}` : `${not(!isNot)}${expectation}`) + } else { + expected = expectedValue + actual = actualValue + } return enhanceError(subject, expected, actual, { ...context, useNotInLabel: false }, verb, expectation, '', options) } + +const isSuccess = (isNot: boolean, result: boolean): boolean => { + return isNot ? !result : result +} diff --git a/src/utils.ts b/src/utils.ts index e665e2bdd..75c3ac2b1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,9 +3,9 @@ import type { ParsedCSSValue } from 'webdriverio' import { expect } from 'expect' -import type { WdioElementMaybePromise } from './types.js' +import type { WdioElementMaybePromise, WdioElementOrArrayMaybePromise } from './types.js' import { wrapExpectedWithArray } from './util/elementsUtil.js' -import { executeCommand } from './util/executeCommand.js' +import { executeCommand, executeCommandWithStrategy } from './util/executeCommand.js' import { enhanceError, enhanceErrorBe } from './util/formatMessage.js' import { waitUntil } from './util/waitUntil.js' @@ -64,29 +64,35 @@ export function getAsymmetricMatcherValue( } async function executeCommandBe( - received: WdioElementMaybePromise, + received: WdioElementOrArrayMaybePromise, command: (el: WebdriverIO.Element) => Promise, options: ExpectWebdriverIO.CommandOptions ): ExpectWebdriverIO.AsyncAssertionResult { - const { isNot, verb = 'be' } = this + const { isNot, verb = 'be', allowEmptyElements = false } = this - let el = await received?.getElement() + let subject: WdioElementMaybePromise | unknown = received + let actual: boolean[] | boolean | undefined const pass = await waitUntil( async () => { - const result = await executeCommand.call( - this, - el, - async (element ) => ({ result: await command(element as WebdriverIO.Element) }), - options - ) - el = result.el as WebdriverIO.Element + const result = await executeCommandWithStrategy({ + unresolvedElements: subject, + singleElementCompare: async (element) => { + const result = await command(element) + return { result, value: result } + }, + isNot, + configuration: { allowEmptyElements } + + }) + subject = result.subject + actual = result.actual return result.success }, isNot, { wait: options.wait, interval: options.interval } ) - const message = enhanceErrorBe(el, { ...this, verb }, options) + const message = enhanceErrorBe(subject, actual, { ...this, verb }, options) return { pass, diff --git a/test/matchers/beMatchers.test.ts b/test/matchers/beMatchers.test.ts index 8ad3d9a21..8ba108581 100644 --- a/test/matchers/beMatchers.test.ts +++ b/test/matchers/beMatchers.test.ts @@ -1,5 +1,5 @@ import { vi, test, describe, expect, beforeEach, afterEach } from 'vitest' -import { $ } from '@wdio/globals' +import { $, $$ } from '@wdio/globals' import { lastMatcherWords } from '../__fixtures__/utils.js' import * as Matchers from '../../src/matchers.js' import { executeCommandBe, waitUntil } from '../../src/utils.js' @@ -7,6 +7,7 @@ import { DEFAULT_OPTIONS } from '../../src/constants.js' import stripAnsi from 'strip-ansi' import { toBeChecked, toBeClickable, toBeDisplayedInViewport, toBeEnabled, toBeExisting, toBeFocused, toBePresent, toBeSelected, toExist } from '../../src/matchers.js' import { setDefaultOptions, setOptions } from '../../src/index.js' +import { chainableElementArrayFactory, elementArrayFactory, notFoundElementFactory } from '../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') @@ -51,6 +52,8 @@ describe('be* matchers', () => { let thisContext: { matcherFn: typeof matcherFn } let thisNotContext: { isNot: true, matcherFn: typeof matcherFn } + const verb = matcherFn.name === 'toExist' ? 'to' : 'to be' + let el: ChainablePromiseElement let elementFn: ElementKeyFnTypes @@ -84,7 +87,7 @@ describe('be* matchers', () => { ) expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: 125, interval: 50 }) expect(beforeAssertion).toHaveBeenCalledWith({ - matcherName: elementFn.name === 'isExisting' ? 'toExist': matcherFn.name, + matcherName: elementFn.name === 'isExisting' ? 'toExist': matcherFn.name, // TODO fix in major version the wrong selector name for matcher aliases options: { beforeAssertion, afterAssertion, wait: 125, interval: 50 } }) expect(afterAssertion).toHaveBeenCalledWith({ @@ -128,9 +131,8 @@ describe('be* matchers', () => { const result = await thisNotContext.matcherFn(el) expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` - if (matcherFn.name === 'toExist') {return} expect(stripAnsi(result.message())).toEqual(`\ -Expect $(\`sel\`) not to be ${lastMatcherWords(matcherFn.name)} +Expect $(\`sel\`) not ${verb} ${lastMatcherWords(matcherFn.name)} Expected: "not ${lastMatcherWords(matcherFn.name)}" Received: "${lastMatcherWords(matcherFn.name)}"` @@ -165,15 +167,410 @@ Received: "${lastMatcherWords(matcherFn.name)}"` const result = await thisContext.matcherFn(el, { wait: 0 }) expect(result.pass).toBe(false) - if (matcherFn.name === 'toExist') {return} expect(stripAnsi(result.message())).toEqual(`\ -Expect $(\`sel\`) to be ${lastMatcherWords(matcherFn.name)} +Expect $(\`sel\`) ${verb} ${lastMatcherWords(matcherFn.name)} Expected: "${lastMatcherWords(matcherFn.name)}" Received: "not ${lastMatcherWords(matcherFn.name)}"`) }) }) + describe('given multiple elements', () => { + let elements: ChainablePromiseArray + const selectorName = '$$(`sel`)' + + beforeEach(async () => { + elements = await $$('sel') + elements.forEach(element => { + vi.mocked(element[elementFnName]).mockResolvedValue(true) + }) + }) + + test('wait for success', async () => { + const beforeAssertion = vi.fn() + const afterAssertion = vi.fn() + + const result = await thisContext.matcherFn(elements, { beforeAssertion, afterAssertion, wait: 500 }) + + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalled() + } + + expect(executeCommandBe).toHaveBeenCalledExactlyOnceWith(elements, expect.any(Function), + { + afterAssertion, + beforeAssertion, + wait: 500 + }, + ) + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: 500, interval: undefined }) + expect(result.pass).toEqual(true) + expect(beforeAssertion).toHaveBeenCalledWith({ + matcherName: elementFn.name === 'isExisting' ? 'toExist': matcherFn.name, // TODO fix in major version the wrong selector name for matcher aliases + options: { beforeAssertion, afterAssertion, wait: 500 } + }) + expect(afterAssertion).toHaveBeenCalledWith({ + matcherName: elementFn.name === 'isExisting' ? 'toExist': matcherFn.name, // TODO fix in major version the wrong selector name for matcher aliases + options: { beforeAssertion, afterAssertion, wait: 500 }, + result + }) + }) + + test('success with matcherFn and custom command options', async () => { + const result = await thisContext.matcherFn(elements, { wait: 4, interval: 99 }) + + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalledOnce() + } + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: 4, interval: 99 }) + expect(result.pass).toBe(true) + }) + + test('success with matcherFn and custom command options - only interval', async () => { + const result = await thisContext.matcherFn(elements, { interval: 99 }) + + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalledOnce() + } + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: undefined, interval: 99 }) + expect(result.pass).toBe(true) + }) + + test('success with matcherFn and default command options', async () => { + const result = await thisContext.matcherFn(elements) + + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalledOnce() + } + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: 20, interval: 1 }) + expect(result.pass).toBe(true) + }) + + test('wait but failure', async () => { + vi.mocked(elements[0][elementFnName]).mockRejectedValue(new Error('some error')) + + await expect(() => thisContext.matcherFn(elements)) + .rejects.toThrow('some error') + }) + + test('success on the first attempt', async () => { + const result = await thisContext.matcherFn(elements) + + expect(result.pass).toBe(true) + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalledTimes(1) + } + }) + + test('no wait - failure', async () => { + vi.mocked(elements[0][elementFnName]).mockResolvedValue(false) + + const result = await thisContext.matcherFn(elements, { wait: 0 }) + + expect(result.pass).toBe(false) + expect(elements[0][elementFnName]).toHaveBeenCalledTimes(1) + expect(elements[1][elementFnName]).toHaveBeenCalledTimes(1) + }) + + test('no wait - success', async () => { + const result = await thisContext.matcherFn(elements) + + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { + wait: 20, + interval: 1, + }) + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalled() + } + expect(result.pass).toBe(true) + }) + + test('not - failure - pass should be true', async () => { + const result = await thisNotContext.matcherFn(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not ${verb} ${lastMatcherWords(matcherFn.name)} + +- Expected - 2 ++ Received + 2 + + Array [ +- "not ${lastMatcherWords(matcherFn.name)}", +- "not ${lastMatcherWords(matcherFn.name)}", ++ "${lastMatcherWords(matcherFn.name)}", ++ "${lastMatcherWords(matcherFn.name)}", + ]` + ) + }) + + test('not - success - pass should be false', async () => { + for (const element of elements) { + vi.mocked(element[elementFnName]).mockResolvedValue(false) + } + + const result = await thisNotContext.matcherFn(elements) + + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('not - failure (with wait) - pass should be true', async () => { + const result = await thisNotContext.matcherFn(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + }) + + test('not - success (with wait) - pass should be false', async () => { + for (const element of elements) { + vi.mocked(element[elementFnName]).mockResolvedValue(false) + } + + const result = await thisNotContext.matcherFn(elements) + + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), true, { + wait: 20, + interval: 1, + }) + for (const element of elements) { + expect(element[elementFnName]).toHaveBeenCalled() + } + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('message when both elements fail', async () => { + for (const element of elements) { + vi.mocked(element[elementFnName]).mockResolvedValue(false) + } + + const result = await thisContext.matcherFn(elements) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} ${verb} ${lastMatcherWords(matcherFn.name)} + +- Expected - 2 ++ Received + 2 + + Array [ +- "${lastMatcherWords(matcherFn.name)}", +- "${lastMatcherWords(matcherFn.name)}", ++ "not ${lastMatcherWords(matcherFn.name)}", ++ "not ${lastMatcherWords(matcherFn.name)}", + ]`) + }) + + test('message when a single element fails', async () => { + vi.mocked(elements[0][elementFnName]).mockResolvedValue(false) + + const result = await thisContext.matcherFn(elements) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} ${verb} ${lastMatcherWords(matcherFn.name)} + +- Expected - 1 ++ Received + 1 + + Array [ +- "${lastMatcherWords(matcherFn.name)}", ++ "not ${lastMatcherWords(matcherFn.name)}", + "${lastMatcherWords(matcherFn.name)}", + ]`) + }) + + describe('fails with ElementArray', () => { + let elementsArray: WebdriverIO.ElementArray + + beforeEach(async () => { + elementsArray = await $$('sel').getElements() + for (const element of elementsArray) { + vi.mocked(element[elementFnName]).mockResolvedValue(true) + } + expect(elementsArray).toHaveLength(2) + }) + + test('success with ElementArray', async () => { + const result = await thisContext.matcherFn(elementsArray) + + for (const element of elementsArray) { + expect(element[elementFnName]).toHaveBeenCalled() + } + + expect(result.pass).toBe(true) + }) + + test('fails with ElementArray', async () => { + vi.mocked(elementsArray[1][elementFnName]).mockResolvedValue(false) + + const result = await thisContext.matcherFn(elementsArray, { wait: 0 }) + + for (const element of elementsArray) { + expect(element[elementFnName]).toHaveBeenCalled() + } + + expect(result.pass).toBe(false) + + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} ${verb} ${lastMatcherWords(matcherFn.name)} + +- Expected - 1 ++ Received + 1 + + Array [ + "${lastMatcherWords(matcherFn.name)}", +- "${lastMatcherWords(matcherFn.name)}", ++ "not ${lastMatcherWords(matcherFn.name)}", + ]`) + }) + + describe('given filtered elements (Element[])', () => { + let filteredElements: WebdriverIO.Element[] + test('success with Element[]', async () => { + filteredElements = await elementsArray.filter((element) => element.isExisting()) + + const result = await thisContext.matcherFn(filteredElements) + + for (const element of filteredElements) { + expect(element[elementFnName]).toHaveBeenCalled() + } + expect(result.pass).toBe(true) + }) + + test('fails with Element[]', async () => { + filteredElements = await elementsArray.filter((element) => element.isExisting()) + + vi.mocked(filteredElements[1][elementFnName]).mockResolvedValue(false) + + const result = await thisContext.matcherFn(filteredElements) + + for (const element of filteredElements) { + expect(element[elementFnName]).toHaveBeenCalled() + } + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect [$$(\`sel\`)[0],$$(\`sel\`)[1]] ${verb} ${lastMatcherWords(matcherFn.name)} + +- Expected - 1 ++ Received + 1 + + Array [ + "${lastMatcherWords(matcherFn.name)}", +- "${lastMatcherWords(matcherFn.name)}", ++ "not ${lastMatcherWords(matcherFn.name)}", + ]`) + }) + }) + }) + }) + + describe('Edge cases', () => { + + test.each([ + { elements: [] as unknown as WebdriverIO.Element[], name: 'Element[]', selectorName: '[]' }, + { elements: Promise.resolve([] as WebdriverIO.Element[]), name: 'Promise of Element[]', selectorName: '[]' }, + { elements: elementArrayFactory('EmptyElementArray', 0), name: 'ElementArray', selectorName: '$$(`EmptyElementArray`)' }, + ])('should fail with proper error message when actual is an empty of $name', async ({ elements, selectorName }) => { + const result = await thisContext.matcherFn(elements) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} ${verb} ${lastMatcherWords(matcherFn.name)} + +Expected: "at least one result" +Received: []`) + }) + + test.skipIf(['toExist', 'toBeExisting', 'toBePresent'].includes(matcherFn.name)).each([ + { elements: [] as unknown as WebdriverIO.Element[], name: 'Element[]', selectorName: '[]' }, + { elements: Promise.resolve([] as WebdriverIO.Element[]), name: 'Promise of Element[]', selectorName: '[]' }, + { elements: elementArrayFactory('EmptyElementArray', 0), name: 'ElementArray', selectorName: '$$(`EmptyElementArray`)' }, + ])('not - should fails (pass=true) with proper error message when actual is an empty of $name', async ({ elements, selectorName }) => { + const result = await thisNotContext.matcherFn(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not ${verb} ${lastMatcherWords(matcherFn.name)} + +Expected: "at least one result" +Received: []`) + }) + + test.runIf(['toExist', 'toBeExisting', 'toBePresent'].includes(matcherFn.name)).each([ + { elements: [] as unknown as WebdriverIO.Element[], name: 'Element[]', selectorName: '[]' }, + { elements: Promise.resolve([] as WebdriverIO.Element[]), name: 'Promise of Element[]', selectorName: '[]' }, + { elements: elementArrayFactory('EmptyElementArray', 0), name: 'ElementArray', selectorName: '$$(`EmptyElementArray`)' }, + ])('not - should succeed (pass=false) when matcher uses isExisting and when actual is an empty of $name', async ({ elements }) => { + const result = await thisNotContext.matcherFn(elements) + + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + // TODO view later to handle this case more gracefully + test.skipIf(['toExist', 'toBeExisting', 'toBePresent'].includes(matcherFn.name))('given element is not found then it throws error when an element does not exists', async () => { + const element: WebdriverIO.Element = notFoundElementFactory('sel') + + await expect(thisContext.matcherFn(element)).rejects.toThrow() + }) + + test.runIf(['toExist', 'toBeExisting', 'toBePresent'].includes(matcherFn.name))('given element is not found then it should not throw error with matcher using isExisting when an element does not exists', async () => { + const element: WebdriverIO.Element = notFoundElementFactory('sel') + + const result = await thisContext.matcherFn(element) + + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('given element from out of bound ChainableArray, then it throws error when an element does not exists', async () => { + const element: ChainablePromiseElement = $$('elements')[3] + + await expect(thisContext.matcherFn(element)).rejects.toThrow('Index out of bounds! $$(elements) returned only 2 elements.') + }) + + test('given only one element in array when failures', async () => { + const elements = chainableElementArrayFactory('elements', 1) + vi.mocked(elements[0][elementFnName]).mockResolvedValue(false) + + const results = await thisContext.matcherFn(elements) + + expect(results.pass).toBe(false) + expect(stripAnsi(results.message())).toEqual(`\ +Expect $$(\`elements\`) ${verb} ${lastMatcherWords(matcherFn.name)} + +- Expected - 1 ++ Received + 1 + + Array [ +- "${lastMatcherWords(matcherFn.name)}", ++ "not ${lastMatcherWords(matcherFn.name)}", + ]` + ) + }) + + test('given the first element function fails to retrieve', async () => { + const elements = $$('elements') + + vi.mocked(elements[0][elementFnName]).mockRejectedValue(new Error('Unable to retrieve text for first element')) + vi.mocked(elements[1][elementFnName]).mockResolvedValue(true) + + await expect(thisContext.matcherFn(elements)).rejects.toThrow('Unable to retrieve text for first element') + }) + + test('given the second element getText fails to retrieve', async () => { + const elements = $$('elements') + + vi.mocked(elements[0][elementFnName]).mockResolvedValue(true) + vi.mocked(elements[1][elementFnName]).mockRejectedValue(new Error('Unable to retrieve text for second element')) + + await expect(thisContext.matcherFn(elements)).rejects.toThrow('Unable to retrieve text for second element') + }) + + test('given all elements getText fails to retrieve', async () => { + const elements = $$('elements') + + vi.mocked(elements[0][elementFnName]).mockRejectedValue(new Error('Unable to retrieve text for first element')) + vi.mocked(elements[1][elementFnName]).mockRejectedValue(new Error('Unable to retrieve text for second element')) + + await expect(thisContext.matcherFn(elements)).rejects.toThrow('Unable to retrieve text for first element') + }) + }) + describe.each( [{ fn: setOptions, name: 'setOptions' }, { fn: setDefaultOptions, name: 'setDefaultOptions' }] )('Global default options with $name', ({ fn: setDefaultOptionsFn }) => { diff --git a/test/matchers/element/toBeDisabled.test.ts b/test/matchers/element/toBeDisabled.test.ts index 84447678b..781bb958b 100644 --- a/test/matchers/element/toBeDisabled.test.ts +++ b/test/matchers/element/toBeDisabled.test.ts @@ -1,8 +1,8 @@ import { vi, test, describe, expect, beforeEach } from 'vitest' -import { $, } from '@wdio/globals' +import { $, $$ } from '@wdio/globals' import { toBeDisabled } from '../../../src/matchers/element/toBeDisabled.js' import stripAnsi from 'strip-ansi' -import { waitUntil } from '../../../src/utils.js' +import { executeCommandBe, waitUntil } from '../../../src/utils.js' vi.mock('@wdio/globals') @@ -124,4 +124,189 @@ Received: "disabled"`) expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` }) }) + + describe('given multiple elements', () => { + let elements: ChainablePromiseArray + + beforeEach(async () => { + elements = await $$('sel') + + elements.forEach(element => { + vi.mocked(element.isEnabled).mockResolvedValue(false) + }) + expect(elements).toHaveLength(2) + }) + + test('wait for success', async () => { + const beforeAssertion = vi.fn() + const afterAssertion = vi.fn() + + const result = await thisContext.toBeDisabled(elements, { beforeAssertion, afterAssertion, wait: 30 }) + + for (const element of elements) { + expect(element.isEnabled).toHaveBeenCalledExactlyOnceWith() + } + + expect(executeCommandBe).toHaveBeenCalledExactlyOnceWith(elements, expect.any(Function), + { + afterAssertion, + beforeAssertion, + wait: 30, + }, + ) + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: 30, interval: undefined }) + + expect(result.pass).toBe(true) + expect(beforeAssertion).toHaveBeenCalledWith({ + matcherName: 'toBeDisabled', + options: { beforeAssertion, afterAssertion, wait: 30 } + }) + expect(afterAssertion).toHaveBeenCalledWith({ + matcherName: 'toBeDisabled', + options: { beforeAssertion, afterAssertion, wait: 30 }, + result + }) + }) + + test('success with toBeDisabled and command options', async () => { + const result = await thisContext.toBeDisabled(elements) + + elements.forEach(element => { + expect(element.isEnabled).toHaveBeenCalledExactlyOnceWith() + }) + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { wait: 20, interval: 1 }) + expect(result.pass).toBe(true) + }) + + test('wait but failure', async () => { + vi.mocked(elements[0].isEnabled).mockRejectedValue(new Error('some error')) + + await expect(() => thisContext.toBeDisabled(elements)) + .rejects.toThrow('some error') + }) + + test('success on the first attempt', async () => { + const result = await thisContext.toBeDisabled(elements) + + expect(result.pass).toBe(true) + elements.forEach(element => { + expect(element.isEnabled).toHaveBeenCalledTimes(1) + }) + }) + + test('no wait - failure', async () => { + vi.mocked(elements[0].isEnabled).mockResolvedValue(true) + + const result = await thisContext.toBeDisabled(elements, { wait: 0 }) + + expect(result.pass).toBe(false) + expect(elements[0].isEnabled).toHaveBeenCalledTimes(1) + expect(elements[1].isEnabled).toHaveBeenCalledTimes(1) + }) + + test('no wait - success', async () => { + const result = await thisContext.toBeDisabled(elements) + + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { + wait: 20, + interval: 1, + }) + elements.forEach(element => { + expect(element.isEnabled).toHaveBeenCalledExactlyOnceWith() + }) + expect(result.pass).toBe(true) + }) + + test('not - failure - pass should be true', async () => { + const result = await thisNotContext.toBeDisabled(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect $$(\`sel\`) not to be disabled + +- Expected - 2 ++ Received + 2 + + Array [ +- "not disabled", +- "not disabled", ++ "disabled", ++ "disabled", + ]` + ) + }) + + test('not - success - pass should be false', async () => { + elements.forEach(element => { + vi.mocked(element.isEnabled).mockResolvedValue(true) + }) + + const result = await thisNotContext.toBeDisabled(elements) + + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('not - failure (with wait) - pass should be true', async () => { + const result = await thisNotContext.toBeDisabled(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + }) + + test('not - success (with wait) - pass should be false', async () => { + elements.forEach(element => { + vi.mocked(element.isEnabled).mockResolvedValueOnce(false) + vi.mocked(element.isEnabled).mockResolvedValueOnce(false) + vi.mocked(element.isEnabled).mockResolvedValue(true) + }) + + const result = await thisNotContext.toBeDisabled(elements, { wait: 500 }) + + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), true, { + wait: 500, + interval: undefined, + }) + expect(elements[0].isEnabled).toHaveBeenCalledTimes(3) + expect(elements[1].isEnabled).toHaveBeenCalledTimes(3) + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('message when both elements fail', async () => { + const elements = await $$('sel') + + elements.forEach(element => { + vi.mocked(element.isEnabled).mockResolvedValue(true) + }) + + const result = await thisContext.toBeDisabled(elements) + expect(stripAnsi(result.message())).toEqual(`\ +Expect $$(\`sel\`) to be disabled + +- Expected - 2 ++ Received + 2 + + Array [ +- "disabled", +- "disabled", ++ "not disabled", ++ "not disabled", + ]`) + }) + + test('message when a single element fails', async () => { + vi.mocked(elements[0].isEnabled).mockResolvedValue(true) + + const result = await thisContext.toBeDisabled(elements) + expect(stripAnsi(result.message())).toEqual(`\ +Expect $$(\`sel\`) to be disabled + +- Expected - 1 ++ Received + 1 + + Array [ +- "disabled", ++ "not disabled", + "disabled", + ]`) + }) + }) }) diff --git a/test/matchers/element/toBeDisplayed.test.ts b/test/matchers/element/toBeDisplayed.test.ts index 9b9d040c1..5c9f400bf 100644 --- a/test/matchers/element/toBeDisplayed.test.ts +++ b/test/matchers/element/toBeDisplayed.test.ts @@ -1,11 +1,12 @@ import { vi, test, describe, expect, beforeEach, afterEach } from 'vitest' -import { $ } from '@wdio/globals' +import { $, $$ } from '@wdio/globals' import { toBeDisplayed } from '../../../src/matchers/element/toBeDisplayed.js' import { executeCommandBe, waitUntil } from '../../../src/utils.js' import stripAnsi from 'strip-ansi' import { DEFAULT_OPTIONS } from '../../../src/constants.js' import { setDefaultOptions, setOptions } from '../../../src/index.js' +import { notFoundElementFactory } from '../../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') @@ -206,6 +207,365 @@ Received: "not displayed"`) }) }) + describe.each([ + { elements: await $$('sel'), title: 'awaited ChainablePromiseArray' }, + { elements: await $$('sel').getElements(), title: 'awaited getElements of ChainablePromiseArray (e.g. WebdriverIO.ElementArray)' }, + { elements: await $$('sel').filter((t) => t.isEnabled()), title: 'awaited filtered ChainablePromiseArray (e.g. WebdriverIO.Element[])' }, + { elements: $$('sel'), title: 'non-awaited of ChainablePromiseArray' } + ])('given multiple elements when $title', ({ elements : els, title }) => { + let elements: ChainablePromiseArray | WebdriverIO.ElementArray | WebdriverIO.Element[] + let awaitedElements: typeof elements + + const selectorName = title.includes('filtered') ? '[$$(`sel`)[0],$$(`sel`)[1]]': '$$(`sel`)' + + beforeEach(async () => { + elements = els + + awaitedElements = await elements + awaitedElements.forEach((element) => { + vi.mocked(element.isDisplayed).mockResolvedValue(true) + }) + expect(awaitedElements).toHaveLength(2) + }) + + test('wait for success', async () => { + const beforeAssertion = vi.fn() + const afterAssertion = vi.fn() + + const result = await thisContext.toBeDisplayed(elements, { beforeAssertion, afterAssertion, wait: 500 }) + + awaitedElements.forEach((element) => { + expect(element.isDisplayed).toHaveBeenCalledWith( + { + withinViewport: false, + contentVisibilityAuto: true, + opacityProperty: true, + visibilityProperty: true + } + ) + }) + expect(executeCommandBe).toHaveBeenCalledExactlyOnceWith(elements, expect.any(Function), + { + beforeAssertion: beforeAssertion, + afterAssertion: afterAssertion, + interval: 1, + wait: 500, + }, + ) + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { + wait: 500, + interval: 1, + }) + + expect(result.pass).toBe(true) + expect(beforeAssertion).toHaveBeenCalledWith({ + matcherName: 'toBeDisplayed', + options: { beforeAssertion, afterAssertion, wait: 500 } + }) + expect(afterAssertion).toHaveBeenCalledWith({ + matcherName: 'toBeDisplayed', + options: { beforeAssertion, afterAssertion, wait: 500 }, + result + }) + }) + + test('success with ToBeDisplayed and command options', async () => { + const result = await thisContext.toBeDisplayed(elements, { wait: 1, withinViewport: true }) + + awaitedElements.forEach((element) => { + expect(element.isDisplayed).toHaveBeenCalledWith( + { + withinViewport: true, + contentVisibilityAuto: true, + opacityProperty: true, + visibilityProperty: true + } + ) + }) + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { + wait: 1, + interval: 1, + }) + expect(result.pass).toBe(true) + }) + + test('wait but error', async () => { + vi.mocked(awaitedElements[0].isDisplayed).mockRejectedValue(new Error('some error')) + + await expect(() => thisContext.toBeDisplayed(elements)) + .rejects.toThrow('some error') + }) + + test('failure when no elements exist', async () => { + const noElementsFound: WebdriverIO.Element[] = [] + const result = await thisContext.toBeDisplayed(noElementsFound) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect [] to be displayed + +Expected: "at least one result" +Received: []`) + }) + + test('success on the first attempt', async () => { + const result = await thisContext.toBeDisplayed(elements) + + expect(result.pass).toBe(true) + awaitedElements.forEach((element) => { + expect(element.isDisplayed).toHaveBeenCalledTimes(1) + }) + }) + + test('no wait - failure', async () => { + vi.mocked(awaitedElements[0].isDisplayed).mockResolvedValue(false) + + const result = await thisContext.toBeDisplayed(elements, { wait: 0 }) + + expect(result.pass).toBe(false) + awaitedElements.forEach((element) => { + expect(element.isDisplayed).toHaveBeenCalledTimes(1) + }) + }) + + test('no wait - success', async () => { + const result = await thisContext.toBeDisplayed(elements, { wait: 0 }) + + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), undefined, { + wait: 0, + interval: 1, + }) + awaitedElements.forEach((element) => { + expect(element.isDisplayed).toHaveBeenNthCalledWith(1, + { + withinViewport: false, + contentVisibilityAuto: true, + opacityProperty: true, + visibilityProperty: true + } + ) + }) + expect(result.pass).toBe(true) + }) + + test('not - failure - all elements - pass should be true', async () => { + const result = await thisNotContext.toBeDisplayed(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not to be displayed + +- Expected - 2 ++ Received + 2 + + Array [ +- "not displayed", +- "not displayed", ++ "displayed", ++ "displayed", + ]`) + }) + + test('not - failure when no elements - pass should be true', async () => { + const noElementsFound: WebdriverIO.Element[] = [] + + const result = await thisNotContext.toBeDisplayed(noElementsFound) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect [] not to be displayed + +Expected: "at least one result" +Received: []`) + }) + + test('not - failure - when only first element is not displayed - pass should be true', async () => { + vi.mocked(awaitedElements[0].isDisplayed).mockResolvedValue(false) + vi.mocked(awaitedElements[1].isDisplayed).mockResolvedValue(true) + + const result = await thisNotContext.toBeDisplayed(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ + "not displayed", +- "not displayed", ++ "displayed", + ]`) + }) + + test('not - failure - when only second element is not displayed - pass should be true', async () => { + vi.mocked(awaitedElements[0].isDisplayed).mockResolvedValue(true) + vi.mocked(awaitedElements[1].isDisplayed).mockResolvedValue(false) + + const result = await thisNotContext.toBeDisplayed(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ +- "not displayed", ++ "displayed", + "not displayed", + ]`) + }) + + test('not - success - pass should be false', async () => { + awaitedElements.forEach((element) => { + vi.mocked(element.isDisplayed).mockResolvedValue(false) + }) + + const result = await thisNotContext.toBeDisplayed(elements) + + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('not - failure (with wait) - pass should be true', async () => { + const result = await thisNotContext.toBeDisplayed(elements) + + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + }) + + test('not - success (with wait) - pass should be false', async () => { + awaitedElements.forEach((element) => { + vi.mocked(element.isDisplayed).mockResolvedValue(false) + }) + + const result = await thisNotContext.toBeDisplayed(elements, { wait: 300 }) + + expect(waitUntil).toHaveBeenCalledExactlyOnceWith(expect.any(Function), true, { + wait: 300, + interval: 1, + }) + awaitedElements.forEach((element) => { + expect(element.isDisplayed).toHaveBeenCalledWith( + { + withinViewport: false, + contentVisibilityAuto: true, + opacityProperty: true, + visibilityProperty: true + } + ) + }) + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + + test('message when both elements fail', async () => { + awaitedElements.forEach((element) => { + vi.mocked(element.isDisplayed).mockResolvedValue(false) + }) + + const result = await thisContext.toBeDisplayed(elements) + + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +- Expected - 2 ++ Received + 2 + + Array [ +- "displayed", +- "displayed", ++ "not displayed", ++ "not displayed", + ]`) + }) + + test('message when first element fails', async () => { + vi.mocked(awaitedElements[0].isDisplayed).mockResolvedValue(false) + vi.mocked(awaitedElements[1].isDisplayed).mockResolvedValue(true) + + const result = await thisContext.toBeDisplayed(elements) + + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ +- "displayed", ++ "not displayed", + "displayed", + ]`) + }) + + test('message when second element fails', async () => { + vi.mocked(awaitedElements[0].isDisplayed).mockResolvedValue(true) + vi.mocked(awaitedElements[1].isDisplayed).mockResolvedValue(false) + + const result = await thisContext.toBeDisplayed(elements) + + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ + "displayed", +- "displayed", ++ "not displayed", + ]`) + }) + + test('message when no element fails', async () => { + const noElementsFound: WebdriverIO.Element[] = [] + + const result = await thisContext.toBeDisplayed(noElementsFound) + + expect(stripAnsi(result.message())).toEqual(`\ +Expect [] to be displayed + +Expected: "at least one result" +Received: []`) + }) + }) + + test.for([ + { els: undefined, selectorName: 'undefined' }, + { els: null, selectorName: 'null' }, + { els: 0, selectorName: '0' }, + { els: 1, selectorName: '1' }, + { els: true, selectorName: 'true' }, + { els: false, selectorName: 'false' }, + { els: '', selectorName: '' }, + { els: 'test', selectorName: 'test' }, + { els: {}, selectorName: '{}' }, + { els: [1, 'test'], selectorName: '[1,"test"]' }, + { els: Promise.resolve(true), selectorName: 'true' } + ])('fails for %s', async ({ els, selectorName }) => { + const result = await thisContext.toBeDisplayed(els as any) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +Expected: "displayed" +Received: "not displayed"`) + }) + + describe('not found element', async () => { + let element: WebdriverIO.Element + + beforeEach(async () => { + element = notFoundElementFactory('sel') + }) + + test('throws error when an element does not exists', async () => { + await expect(thisContext.toBeDisplayed(element)).rejects.toThrow("Can't call isDisplayed on element with selector sel because element wasn't found") + }) + }) + describe.each( [{ fn: setOptions, name: 'setOptions' }, { fn: setDefaultOptions, name: 'setDefaultOptions' }] )('Global default options with $name', ({ fn: setDefaultOptionsFn }) => { diff --git a/test/matchers/element/toHaveText.test.ts b/test/matchers/element/toHaveText.test.ts index 476b6a41b..ea0bc4629 100755 --- a/test/matchers/element/toHaveText.test.ts +++ b/test/matchers/element/toHaveText.test.ts @@ -712,6 +712,16 @@ Expected: "webdriverio" Received: undefined`) }) + test.each([ + { elements: [] as unknown as WebdriverIO.Element[], name: 'Element[]', selectorName: '[]' }, + { elements: Promise.resolve([] as WebdriverIO.Element[]), name: 'Promise of Element[]', selectorName: '[]' }, + { elements: elementArrayFactory('EmptyElementArray', 0), name: 'ElementArray', selectorName: '$$(`EmptyElementArray`)' }, + ])('not - should succeed when actual is an empty of $name - legacy behavior to deprecate!', async ({ elements }) => { + const result = await thisNotContext.toHaveText(elements, 'webdriverio') + + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) + // TODO view later to handle this case more gracefully test('given element is not found then it throws error when an element does not exists', async () => { const element: WebdriverIO.Element = notFoundElementFactory('sel') diff --git a/test/util/formatMessage.test.ts b/test/util/formatMessage.test.ts index 4c6d8c6ce..0763fe109 100644 --- a/test/util/formatMessage.test.ts +++ b/test/util/formatMessage.test.ts @@ -432,7 +432,7 @@ Expect ${elementName} not to have text const isNot = false test('when isNot is false and failure with result having pass=false', () => { - const message = stripAnsi(enhanceErrorBe(subject, { isNot, verb, expectation }, options )) + const message = stripAnsi(enhanceErrorBe(subject, [false], { isNot, verb, expectation }, options )) expect(message).toEqual(`\ Expect $(\`element\`) to be displayed @@ -442,7 +442,7 @@ Received: "not displayed"`) test('with custom message', () => { const customMessage = 'Custom Error Message' - const message = stripAnsi(enhanceErrorBe(subject, { isNot, verb, expectation }, { ...options, message: customMessage })) + const message = stripAnsi(enhanceErrorBe(subject, [false], { isNot, verb, expectation }, { ...options, message: customMessage })) expect(message).toEqual(`\ Custom Error Message Expect $(\`element\`) to be displayed @@ -453,7 +453,7 @@ Received: "not displayed"`) test('when isNot is true and failure with result having pass=true (inverted later by Jest)', () => { const isNot = true - const message = stripAnsi(enhanceErrorBe(subject, { isNot, verb, expectation }, options)) + const message = stripAnsi(enhanceErrorBe(subject, [true], { isNot, verb, expectation }, options)) expect(message).toEqual(`\ Expect $(\`element\`) not to be displayed @@ -471,7 +471,7 @@ Received: "displayed"`) { actual: {}, selectorName: '{}' }, { actual: ['1', '2'], selectorName: '["1","2"]' }, ])('should return failure message for unsupported type $actual when isNot is false and not result from element function call', async ({ actual: subject, selectorName }) => { - const result = await enhanceErrorBe(subject as any, { isNot, verb, expectation }, options) + const result = await enhanceErrorBe(subject as any, [], { isNot, verb, expectation }, options) expect(stripAnsi(result)).toEqual(`\ Expect ${selectorName} to be displayed @@ -489,7 +489,7 @@ Received: "not displayed"`) { actual: {}, selectorName: '{}' }, { actual: ['1', '2'], selectorName: '["1","2"]' }, ])('should return failure message for unsupported type $actual when isNot is true and not result from element function call', async ({ actual: subject, selectorName }) => { - const result = await enhanceErrorBe(subject as any, { isNot: true, verb, expectation }, options) + const result = await enhanceErrorBe(subject as any, [], { isNot: true, verb, expectation }, options) expect(stripAnsi(result)).toEqual(`\ Expect ${selectorName} not to be displayed @@ -499,224 +499,124 @@ Received: "displayed"`) }) }) - describe('given multiple elements', () => { - const elements = elementArrayFactory('elements', 2) - const elementName = '$$(`elements`)' + describe('given multiples elements', () => { + const subject = elementArrayFactory('elements', 2) - describe('elements when isNot is false', () => { + describe('when isNot is false', () => { const isNot = false - test('all elements failure', () => { - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Actual Value 1', 'Test Actual Value 2'] - const actualFailureMessage = stripAnsi(enhanceError( - elements, - expected, - actual, - { isNot }, - 'have', - 'text', - )) - - expect(actualFailureMessage).toEqual(`\ -Expect ${elementName} to have text + test('failure with all results having pass=false', () => { + const message = stripAnsi(enhanceErrorBe(subject, [false, false], { isNot, verb, expectation }, options )) + expect(message).toEqual(`\ +Expect $$(\`elements\`) to be displayed - Expected - 2 + Received + 2 Array [ -- "Test Expected Value 1", -- "Test Expected Value 2", -+ "Test Actual Value 1", -+ "Test Actual Value 2", +- "displayed", +- "displayed", ++ "not displayed", ++ "not displayed", ]`) }) - test('First elements failure', () => { - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Actual Value 1', 'Test Expected Value 2'] - - const actualFailureMessage = stripAnsi(enhanceError( - elements, - expected, - actual, - { isNot }, - 'have', - 'text', - )) - - expect(actualFailureMessage).toEqual(`\ -Expect ${elementName} to have text + test('failure with first results having pass=true', () => { + const message = enhanceErrorBe(subject, [true, false], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect $$(\`elements\`) to be displayed - Expected - 1 + Received + 1 Array [ -- "Test Expected Value 1", -+ "Test Actual Value 1", - "Test Expected Value 2", + "displayed", +- "displayed", ++ "not displayed", ]`) }) - test('Seconds elements failure', () => { - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Expected Value 1', 'Test Actual Value 2'] - - const actualFailureMessage = stripAnsi(enhanceError( - elements, - expected, - actual, - { isNot }, - 'have', - 'text', - )) - - expect(actualFailureMessage).toEqual(`\ -Expect ${elementName} to have text + test('failure with second results having pass=true', () => { + const message = enhanceErrorBe(subject, [false, true], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect $$(\`elements\`) to be displayed - Expected - 1 + Received + 1 Array [ - "Test Expected Value 1", -- "Test Expected Value 2", -+ "Test Actual Value 2", +- "displayed", ++ "not displayed", + "displayed", ]`) }) - }) - - describe('elements when isNot is true', () => { - const isNot = true - test('all elements failure then all values are highlighted as failure', () => { - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Expected Value 1', 'Test Expected Value 2'] - const actualFailureMessage = stripAnsi(enhanceError( - elements, - expected, - actual, - { isNot }, - 'have', - 'text', - )) + test('when no element', () => { + const message = enhanceErrorBe([], [], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect [] to be displayed - expect(actualFailureMessage).toEqual(`\ -Expect ${elementName} not to have text - -Expected [not]: ["Test Expected Value 1", "Test Expected Value 2"] -Received : ["Test Expected Value 1", "Test Expected Value 2"]` - ) +Expected: "at least one result" +Received: []`) }) + }) - test('First elements failure then only first values are highlighted as failure', () => { - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Expected Value 1', 'Test Actual Value 2'] - - const actualFailureMessage = stripAnsi(enhanceError( - elements, - expected, - actual, - { isNot }, - 'have', - 'text', - )) + describe('when isNot is true where failure are pass=true since Jest inverts the result', () => { + const isNot = true - expect(actualFailureMessage).toEqual(`\ -Expect ${elementName} not to have text + test('failure with all results having pass=true', () => { + const message = enhanceErrorBe(subject, [true, true], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect $$(\`elements\`) not to be displayed -- Expected [not] - 1 -+ Received + 1 +- Expected - 2 ++ Received + 2 Array [ - "Test Expected Value 1", -- "Test Expected Value 2", -+ "Test Actual Value 2", - ]` - ) +- "not displayed", +- "not displayed", ++ "displayed", ++ "displayed", + ]`) }) - test('Second elements failure then only second values are highlighted as failure', () => { - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Actual Value 1', 'Test Expected Value 2'] + test('failure with first results having success pass=false (inverted later)', () => { + const message = enhanceErrorBe(subject, [false, true], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect $$(\`elements\`) not to be displayed - const actualFailureMessage = enhanceError( - elements, - expected, - actual, - { isNot }, - 'have', - 'text', - ) - - expect(stripAnsi(actualFailureMessage)).toEqual(`\ -Expect ${elementName} not to have text - -- Expected [not] - 1 -+ Received + 1 +- Expected - 1 ++ Received + 1 Array [ -- "Test Expected Value 1", -+ "Test Actual Value 1", - "Test Expected Value 2", - ]` - ) - + "not displayed", +- "not displayed", ++ "displayed", + ]`) }) - }) - describe('given subject is Element[]', () => { - test('should return element selector name inside the array', async () => { - const arrayOfElements = [elementFactory('element1'), elementFactory('element2')] - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Actual Value 1', 'Test Expected Value 2'] - - const actualFailureMessage = enhanceError( - arrayOfElements, - expected, - actual, - { isNot: false }, - 'have', - 'text', - ) - expect(stripAnsi(actualFailureMessage)).toEqual(`\ -Expect [$(\`element1\`),$(\`element2\`)] to have text + test('failure with second results having success pass=false (inverted later)', () => { + const message = enhanceErrorBe(subject, [true, false], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect $$(\`elements\`) not to be displayed - Expected - 1 + Received + 1 Array [ -- "Test Expected Value 1", -+ "Test Actual Value 1", - "Test Expected Value 2", - ]` - ) +- "not displayed", ++ "displayed", + "not displayed", + ]`) }) - test('should return element selector name truncated when array of elements is too long', async () => { - const arrayOfElements = Array(100).fill(null).map((_, index) => elementFactory(`element${index + 1}`)) - const expected = ['Test Expected Value 1', 'Test Expected Value 2'] - const actual = ['Test Actual Value 1', 'Test Expected Value 2'] - - const actualFailureMessage = enhanceError( - arrayOfElements, - expected, - actual, - { isNot: false }, - 'have', - 'text', - ) - expect(stripAnsi(actualFailureMessage)).toEqual(`\ -Expect [$(\`element1\`),$(\`element2\`),$(\`element3\`),$(\`element4\`),$(\`element5\`),$(\`element6\`),$(\`element7\`),$... to have text - -- Expected - 1 -+ Received + 1 + test('when no elements', () => { + const message = enhanceErrorBe([], [], { isNot, verb, expectation }, options ) + expect(stripAnsi(message)).toEqual(`\ +Expect [] not to be displayed - Array [ -- "Test Expected Value 1", -+ "Test Actual Value 1", - "Test Expected Value 2", - ]` - ) +Expected: "at least one result" +Received: []`) }) }) }) diff --git a/test/utils.test.ts b/test/utils.test.ts index b67aecfd8..2798eb3ae 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,10 +1,11 @@ import { describe, test, expect, beforeEach, vi } from 'vitest' -import { $ } from '@wdio/globals' -import { compareObject, compareText, compareTextWithArray, executeCommand, executeCommandBe, getAsymmetricMatcherValue, isAsymmetricMatcher, isInversedStringContainingMatcher, isStringContainingMatcherLike, waitUntil } from '../src/utils' +import { $, $$ } from '@wdio/globals' +import { compareObject, compareText, compareTextWithArray, executeCommandBe, getAsymmetricMatcherValue, isAsymmetricMatcher, isInversedStringContainingMatcher, isStringContainingMatcherLike, waitUntil } from '../src/utils' import { jasmine } from './__mocks__/jasmine' import { CommandOptions } from 'expect-webdriverio' import stripAnsi from 'strip-ansi' import { enhanceErrorBe } from '../src/util/formatMessage' +import { executeCommandWithStrategy } from '../src/util/executeCommand' vi.mock('@wdio/globals') @@ -13,6 +14,7 @@ vi.mock('../src/util/executeCommand', async (importOriginal) => { return { ...actual, executeCommand: vi.spyOn(actual, 'executeCommand'), + executeCommandWithStrategy: vi.spyOn(actual, 'executeCommandWithStrategy'), } }) vi.mock('../src/util/formatMessage', async (importOriginal) => { @@ -183,8 +185,6 @@ describe('utils', () => { describe('given no elements', () => { test('should fail given undefined', async () => { - command = vi.fn().mockResolvedValue(false) - const result = await executeCommandBe.call(context, undefined as any, command, options) expect(result.pass).toBe(false) @@ -196,9 +196,7 @@ Received: "not displayed"`) expect(waitUntil).toHaveBeenCalled() }) - // TODO Bring back with $$ support - test.skip('should fail given empty array', async () => { - // @ts-expect-error bring back with $$ support + test('should fail given empty array', async () => { const result = await executeCommandBe.call(context, [], command, options) expect(result.pass).toBe(false) @@ -212,33 +210,41 @@ Received: []`) }) describe('given single element', () => { - let chainable: ChainablePromiseElement - let element: WebdriverIO.Element + let received: ChainablePromiseElement - beforeEach(async () => { - chainable = $('element1') - element = await chainable.getElement() + beforeEach(() => { + received = $('element1') }) test('should pass given ChainableElement', async () => { - const result = await executeCommandBe.call(context, chainable, command, options) + const result = await executeCommandBe.call(context, received, command, options) expect(result.pass).toBe(true) - expect(executeCommand).toHaveBeenCalledWith(element, expect.any(Function), options) + expect(executeCommandWithStrategy).toHaveBeenCalledWith({ + unresolvedElements: received, + singleElementCompare: expect.any(Function), + isNot: false, + configuration: { allowEmptyElements: false } + }) expect(waitUntil).toHaveBeenCalledWith(expect.any(Function), false, options) }) test('should pass given WebdriverIO.Element', async () => { - const result = await executeCommandBe.call(context, element, command, options) + const result = await executeCommandBe.call(context, received, command, options) expect(result.pass).toBe(true) - expect(executeCommand).toHaveBeenCalledWith(element, expect.any(Function), options) + expect(executeCommandWithStrategy).toHaveBeenCalledWith({ + unresolvedElements: received, + singleElementCompare: expect.any(Function), + isNot: false, + configuration: { allowEmptyElements: false } + }) }) test('should fail if command returns false', async () => { vi.mocked(command).mockResolvedValue(false) - const result = await executeCommandBe.call(context, chainable, command, options) + const result = await executeCommandBe.call(context, received, command, options) expect(result.pass).toBe(false) expect(stripAnsi(result.message())).toEqual(`\ @@ -247,7 +253,8 @@ Expect $(\`element1\`) to be displayed Expected: "displayed" Received: "not displayed"`) expect(enhanceErrorBe).toHaveBeenCalledWith( - element, + await received, + false, expect.objectContaining({ isNot: false }), options ) @@ -267,11 +274,12 @@ Received: "not displayed"`) }) test('should succeed so pass=false since it is inverted later', async () => { - const result = await executeCommandBe.call(negatedContext, chainable, command, options) + const result = await executeCommandBe.call(negatedContext, received, command, options) expect(result.pass).toBe(false) expect(enhanceErrorBe).toHaveBeenCalledWith( - await chainable, + await received, + false, { expectation: 'displayed', isNot: true, @@ -284,7 +292,7 @@ Received: "not displayed"`) test('should failed so pass=true since it is inverted later', async () => { vi.mocked(command).mockResolvedValue(true) - const result = await executeCommandBe.call(negatedContext, chainable, command, options) + const result = await executeCommandBe.call(negatedContext, received, command, options) expect(result.pass).toBe(true) expect(stripAnsi(result.message())).toEqual(`\ @@ -293,7 +301,8 @@ Expect $(\`element1\`) not to be displayed Expected: "not displayed" Received: "displayed"`) expect(enhanceErrorBe).toHaveBeenCalledWith( - await chainable, + await received, + true, { expectation: 'displayed', isNot: true, @@ -305,6 +314,199 @@ Received: "displayed"`) }) }) }) + + describe('given multiple elements', () => { + const elements = $$('elements') + const selectorName = '$$(`elements`)' + + test('should pass given ChainableArray', async () => { + const result = await executeCommandBe.call(context, elements, command, options) + + expect(result.pass).toBe(true) + expect(executeCommandWithStrategy).toHaveBeenCalledWith({ + unresolvedElements: elements, + singleElementCompare: expect.any(Function), + isNot: false, + configuration: { allowEmptyElements: false } + }) + expect(command).toHaveBeenCalledTimes(2) + expect(waitUntil).toHaveBeenCalledWith(expect.any(Function), false, options) + }) + + test('should pass given ElementArray', async () => { + const elementArray: WebdriverIO.ElementArray = await elements.getElements() + + const result = await executeCommandBe.call(context, elementArray, command, options) + + expect(result.pass).toBe(true) + expect(executeCommandWithStrategy).toHaveBeenCalledWith({ + unresolvedElements: elementArray, + singleElementCompare: expect.any(Function), + isNot: false, + configuration: { allowEmptyElements: false } + }) + expect(command).toHaveBeenCalledTimes(2) + }) + + test('should pass given Element[]', async () => { + const elementArray: WebdriverIO.Element[] = await (await elements.getElements()).filter(el => el.isDisplayed()) + + const result = await executeCommandBe.call(context, elementArray, command, options) + + expect(result.pass).toBe(true) + expect(executeCommandWithStrategy).toHaveBeenCalledWith({ + unresolvedElements: elementArray, + singleElementCompare: expect.any(Function), + isNot: false, + configuration: { allowEmptyElements: false } + }) + expect(command).toHaveBeenCalledTimes(2) + }) + + test('should fail when first element fails', async () => { + vi.mocked(command).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + const result = await executeCommandBe.call(context, elements, command, options) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ +- "displayed", ++ "not displayed", + "displayed", + ]`) + }) + + test('should fail when last element fails', async () => { + vi.mocked(command).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + const result = await executeCommandBe.call(context, elements, command, options) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ + "displayed", +- "displayed", ++ "not displayed", + ]`) + }) + + test('should fail when all elements fail', async () => { + vi.mocked(command).mockResolvedValue(false) + + const result = await executeCommandBe.call(context, elements, command, options) + + expect(result.pass).toBe(false) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} to be displayed + +- Expected - 2 ++ Received + 2 + + Array [ +- "displayed", +- "displayed", ++ "not displayed", ++ "not displayed", + ]`) + }) + + describe('given isNot is true', () => { + let negatedContext: { isNot: boolean; expectation: string; verb: string } + + beforeEach(() => { + // Success for `.not` + vi.mocked(command).mockResolvedValue(false) + negatedContext = { + expectation: 'displayed', + verb: 'be', + isNot: true + } + }) + + test('should succeed so pass=false since it is inverted later', async () => { + const result = await executeCommandBe.call(negatedContext, elements, command, options) + + expect(result.pass).toBe(false) + expect(executeCommandWithStrategy).toHaveBeenCalledWith({ + unresolvedElements: elements, + singleElementCompare: expect.any(Function), + isNot: true, + configuration: { allowEmptyElements: false } + }) + expect(command).toHaveBeenCalledTimes(2) + expect(waitUntil).toHaveBeenCalledWith(expect.any(Function), true, options) + }) + + test('should fail (so pass=true since it is inverted later) when first element fails', async () => { + vi.mocked(command).mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + const result = await executeCommandBe.call(negatedContext, elements, command, options) + + expect(result.pass).toBe(true) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ +- "not displayed", ++ "displayed", + "not displayed", + ]`) + }) + + test('should fail (so pass=true since it is inverted later) when last element fails', async () => { + vi.mocked(command).mockResolvedValueOnce(false).mockResolvedValueOnce(true) + + const result = await executeCommandBe.call(negatedContext, elements, command, options) + + expect(result.pass).toBe(true) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not to be displayed + +- Expected - 1 ++ Received + 1 + + Array [ + "not displayed", +- "not displayed", ++ "displayed", + ]`) + }) + + test('should fail (so pass=true since it is inverted later) when all elements fail', async () => { + vi.mocked(command).mockResolvedValue(true) + + const result = await executeCommandBe.call(negatedContext, elements, command, options) + + expect(result.pass).toBe(true) + expect(stripAnsi(result.message())).toEqual(`\ +Expect ${selectorName} not to be displayed + +- Expected - 2 ++ Received + 2 + + Array [ +- "not displayed", +- "not displayed", ++ "displayed", ++ "displayed", + ]`) + }) + }) + }) }) describe(isAsymmetricMatcher, () => { diff --git a/vitest.config.ts b/vitest.config.ts index f59d82c39..039608bf0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,10 +32,10 @@ export default defineConfig({ 'types-checks-filter-out-node_modules.js', ], thresholds: { - lines: 90.4, - functions: 89.8, - statements: 90.3, - branches: 83.7, + lines: 91.8, + functions: 90.9, + statements: 91.8, + branches: 86.2, } } }