diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82902dd68..b5d24fd51 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,7 +21,5 @@ jobs: node-version: ${{ matrix.node-version }} - name: Install Dependencies run: npm install --force - - name: Build - run: npm run build - - name: Run Tests - run: npm test + - name: Run All Checks + run: npm run checks:all diff --git a/docs/API.md b/docs/API.md index 9f09e3803..f3219dc92 100644 --- a/docs/API.md +++ b/docs/API.md @@ -14,6 +14,10 @@ it('product page smoke', async () => { // These won't throw immediately if they fail await expect.soft(await $('h1').getText()).toEqual('Basketball Shoes'); await expect.soft(await $('#price').getText()).toMatch(/€\d+/); + + // Also work with basic matcher + const h1Text = await $('h1').getText() + expect.soft(h1Text).toEqual('Basketball Shoes'); // Regular assertions still throw immediately await expect(await $('.add-to-cart').isClickable()).toBe(true); @@ -75,7 +79,7 @@ export const config = { // ... services: [ // ...other services - [SoftAssertionService] + [SoftAssertionService, {}] ], // ... } diff --git a/package.json b/package.json index 2c14c4626..6fff2c5b8 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,9 @@ "build": "run-s clean compile", "clean": "run-p clean:*", "clean:build": "rimraf ./lib", - "compile": "tsc --build tsconfig.build.json", + "compile": "run-s compile:*", + "compile:lib": "tsc --build tsconfig.build.json", + "compile:check": "if [ ! -f lib/index.js ] || [ $(find lib -type f | wc -l) -le 30 ]; then echo 'File structure under lib is broken'; exit 1; fi", "tsc:root-types": "node types-checks-filter-out-node_modules.js", "test": "run-s test:*", "test:tsc": "tsc --project tsconfig.json --noEmit --rootDir .", diff --git a/src/matchers/element/toHaveText.ts b/src/matchers/element/toHaveText.ts index edcab174e..bb0cdb22d 100644 --- a/src/matchers/element/toHaveText.ts +++ b/src/matchers/element/toHaveText.ts @@ -38,7 +38,7 @@ async function condition(el: WebdriverIO.Element | WebdriverIO.ElementArray, tex } export async function toHaveText( - received: ChainablePromiseElement | ChainablePromiseArray, + received: ChainablePromiseElement | ChainablePromiseArray | WebdriverIO.Element | WebdriverIO.ElementArray, expectedValue: string | RegExp | WdioAsymmetricMatcher | Array, options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS ) { diff --git a/src/softExpect.ts b/src/softExpect.ts index 31edd5402..85c801afd 100644 --- a/src/softExpect.ts +++ b/src/softExpect.ts @@ -1,15 +1,18 @@ -import { expect, matchers } from './index.js' +import { expect } from './index.js' import { SoftAssertService } from './softAssert.js' +import type { SyncExpectationResult } from 'expect' + +const isPossibleMatcher = (propName: string) => propName.startsWith('to') && propName.length > 2 /** * Creates a soft assertion wrapper using lazy evaluation * Only creates matchers when they're actually accessed */ -const createSoftExpect = (actual: T): ExpectWebdriverIO.Matchers, T> => { +const createSoftExpect = (actual: T): ExpectWebdriverIO.Matchers | void, T> => { const softService = SoftAssertService.getInstance() // Use a simple proxy that creates matchers on-demand - return new Proxy({} as ExpectWebdriverIO.Matchers, T>, { + return new Proxy({} as ExpectWebdriverIO.Matchers | void, T>, { get(target, prop) { const propName = String(prop) @@ -23,8 +26,8 @@ const createSoftExpect = (actual: T): ExpectWebdriverIO.Matchers(actual: T): ExpectWebdriverIO.Matchers(actual: T, softService: SoftAssertService) => { - return new Proxy({} as ExpectWebdriverIO.Matchers, T>, { - get(target, prop) { + return new Proxy({} as ExpectWebdriverIO.Matchers | void, T>, { + get(_target, prop) { const propName = String(prop) - if (matchers.has(propName)) { - return createSoftMatcher(actual, propName, softService, 'not') - } - return undefined + return isPossibleMatcher(propName) ? createSoftMatcher(actual, propName, softService, 'not') : undefined } }) } @@ -53,13 +53,10 @@ const createSoftNotProxy = (actual: T, softService: SoftAssertService) => { * Creates a soft chain proxy (resolves/rejects) */ const createSoftChainProxy = (actual: T, chainType: string, softService: SoftAssertService) => { - return new Proxy({} as ExpectWebdriverIO.Matchers, T>, { - get(target, prop) { + return new Proxy({} as ExpectWebdriverIO.Matchers | void, T>, { + get(_target, prop) { const propName = String(prop) - if (matchers.has(propName)) { - return createSoftMatcher(actual, propName, softService, chainType) - } - return undefined + return isPossibleMatcher(propName) ? createSoftMatcher(actual, propName, softService, chainType) : undefined } }) } @@ -73,7 +70,7 @@ const createSoftMatcher = ( softService: SoftAssertService, prefix?: string ) => { - return async (...args: unknown[]) => { + return (...args: unknown[]): ExpectWebdriverIO.AsyncAssertionResult | SyncExpectationResult => { try { // Build the expectation chain // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -87,20 +84,31 @@ const createSoftMatcher = ( expectChain = expectChain.rejects } - return await ((expectChain as unknown) as Record ExpectWebdriverIO.AsyncAssertionResult>)[matcherName](...args) + // In case of matchers failures we jump into the catch block below + const assertionResult: ExpectWebdriverIO.AsyncAssertionResult | SyncExpectationResult = expectChain[matcherName](...args) - } catch (error) { - // Record the failure - const fullMatcherName = prefix ? `${prefix}.${matcherName}` : matcherName - softService.addFailure(error as Error, fullMatcherName) - - // Return a passing result to continue execution - return { - pass: true, - message: () => `Soft assertion failed: ${fullMatcherName}` + // Handle async matchers, and allow to not be a promise for basic non-async matchers + if ( assertionResult instanceof Promise) { + return assertionResult.catch((error: Error) => handlingMatcherFailure(prefix, matcherName, softService, error)) } + return assertionResult + + } catch (error) { + return handlingMatcherFailure(prefix, matcherName, softService, error as Error) } } } +function handlingMatcherFailure(prefix: string | undefined, matcherName: string, softService: SoftAssertService, error: unknown) { + // Record the failure + const fullMatcherName = prefix ? `${prefix}.${matcherName}` : matcherName + softService.addFailure(error as Error, fullMatcherName) + + // Return a passing result to continue execution + return { + pass: true, + message: () => `Soft assertion failed: ${fullMatcherName}` + } satisfies SyncExpectationResult +} + export default createSoftExpect diff --git a/test/__mocks__/@wdio/globals.ts b/test/__mocks__/@wdio/globals.ts index d9f4a3b4a..73a14a414 100644 --- a/test/__mocks__/@wdio/globals.ts +++ b/test/__mocks__/@wdio/globals.ts @@ -3,7 +3,7 @@ * This file exist for better typed mock implementation, so that we can follow wdio/globals API updates more easily. */ import { vi } from 'vitest' -import type { ChainablePromiseArray, ChainablePromiseElement } from 'webdriverio' +import type { ChainablePromiseArray, ChainablePromiseElement, ParsedCSSValue } from 'webdriverio' import type { RectReturn } from '@wdio/protocols' export type Size = Pick @@ -20,46 +20,172 @@ const getElementMethods = () => ({ getHTML: vi.spyOn({ getHTML: async () => { return '' } }, 'getHTML'), getComputedLabel: vi.spyOn({ getComputedLabel: async () => 'Computed Label' }, 'getComputedLabel'), getComputedRole: vi.spyOn({ getComputedRole: async () => 'Computed Role' }, 'getComputedRole'), + getAttribute: vi.spyOn({ getAttribute: async (_attr: string) => 'some attribute' }, 'getAttribute'), + getCSSProperty: vi.spyOn({ getCSSProperty: async (_prop: string, _pseudo?: string) => + ({ value: 'colorValue', parsed: {} } satisfies ParsedCSSValue) }, 'getCSSProperty'), getSize: vi.spyOn({ getSize: async (prop?: 'width' | 'height') => { if (prop === 'width') { return 100 } if (prop === 'height') { return 50 } return { width: 100, height: 50 } satisfies Size - } }, 'getSize') as unknown as WebdriverIO.Element['getSize'], - getAttribute: vi.spyOn({ getAttribute: async (_attr: string) => 'some attribute' }, 'getAttribute'), + } }, + // Force wrong size & number typing, fixed by https://github.com/webdriverio/webdriverio/pull/15003 + 'getSize') as unknown as WebdriverIO.Element['getSize'], + $, + $$, } satisfies Partial) -function $(_selector: string) { - const element = { +export const elementFactory = (_selector: string, index?: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browser): WebdriverIO.Element => { + const partialElement = { selector: _selector, ...getElementMethods(), + index, + $, + $$, + parent + } satisfies Partial + + const element = partialElement as unknown as WebdriverIO.Element + element.getElement = vi.fn().mockResolvedValue(element) + + // Note: an element found has element.elementId while a not found has element.error + element.elementId = `${_selector}${index ? '-' + index : ''}` + + return element +} + +export const notFoundElementFactory = (_selector: string, index?: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browser): WebdriverIO.Element => { + const partialElement = { + selector: _selector, + index, $, - $$ - } satisfies Partial as unknown as WebdriverIO.Element - element.getElement = async () => Promise.resolve(element) - return element as unknown as ChainablePromiseElement + $$, + isExisting: vi.fn().mockResolvedValue(false), + parent + } satisfies Partial + + const element = partialElement as unknown as WebdriverIO.Element + + // Note: an element found has element.elementId while a not found has element.error + const elementId = `${_selector}${index ? '-' + index : ''}` + const error = (functionName: string) => new Error(`Can't call ${functionName} on element with selector ${elementId} because element wasn't found`) + + // Mimic element not found by throwing error on any method call beisde isExisting + const notFoundElement = new Proxy(element, { + get(target, prop) { + if (prop in element) { + const value = element[prop as keyof WebdriverIO.Element] + return value + } + if (['then', 'catch', 'toStringTag'].includes(prop as string) || typeof prop === 'symbol') { + const value = Reflect.get(target, prop) + return typeof value === 'function' ? value.bind(target) : value + } + element.error = error(prop as string) + return () => { throw element.error } + } + }) + + element.getElement = vi.fn().mockResolvedValue(notFoundElement) + + return notFoundElement } -function $$(selector: string) { - const length = (this)?._length || 2 - const elements = Array(length).fill(null).map((_, index) => { - const element = { - selector, - index, - ...getElementMethods(), - $, - $$ - } satisfies Partial as unknown as WebdriverIO.Element - element.getElement = async () => Promise.resolve(element) - return element - }) satisfies WebdriverIO.Element[] as unknown as WebdriverIO.ElementArray - - elements.foundWith = '$$' - elements.props = [] - elements.props.length = length - elements.selector = selector - elements.getElements = async () => elements - elements.length = length - return elements as unknown as ChainablePromiseArray +const $ = vi.fn((_selector: string) => { + const element = elementFactory(_selector) + + // Wdio framework does return a Promise-wrapped element, so we need to mimic this behavior + const chainablePromiseElement = Promise.resolve(element) as unknown as ChainablePromiseElement + + // Ensure `'getElement' in chainableElement` is false while allowing to use `await chainableElement.getElement()` + const runtimeChainableElement = new Proxy(chainablePromiseElement, { + get(target, prop) { + if (prop in element) { + return element[prop as keyof WebdriverIO.Element] + } + const value = Reflect.get(target, prop) + return typeof value === 'function' ? value.bind(target) : value + } + }) + return runtimeChainableElement +}) + +const $$ = vi.fn((selector: string) => { + const length = (this as any)?._length || 2 + return chainableElementArrayFactory(selector, length) +}) + +export function elementArrayFactory(selector: string, length?: number): WebdriverIO.ElementArray { + const elements: WebdriverIO.Element[] = Array(length).fill(null).map((_, index) => elementFactory(selector, index)) + + const elementArray = elements as unknown as WebdriverIO.ElementArray + + elementArray.foundWith = '$$' + elementArray.props = [] + elementArray.selector = selector + elementArray.getElements = vi.fn().mockResolvedValue(elementArray) + elementArray.filter = async (fn: (element: WebdriverIO.Element, index: number, array: T[]) => boolean | Promise) => { + const results = await Promise.all(elements.map((el, i) => fn(el, i, elements as unknown as T[]))) + return Array.prototype.filter.call(elements, (_, i) => results[i]) + } + elementArray.parent = browser + + // TODO Verify if we need to implement other array methods + // [Symbol.iterator]: array[Symbol.iterator].bind(array) + // filter: vi.fn().mockReturnThis(), + // map: vi.fn().mockReturnThis(), + // find: vi.fn().mockReturnThis(), + // forEach: vi.fn(), + // some: vi.fn(), + // every: vi.fn(), + // slice: vi.fn().mockReturnThis(), + // toArray: vi.fn().mockReturnThis(), + // getElements: vi.fn().mockResolvedValue(array) + + return elementArray +} + +export function chainableElementArrayFactory(selector: string, length: number) { + const elementArray = elementArrayFactory(selector, length) + + // Wdio framework does return a Promise-wrapped element, so we need to mimic this behavior + const chainablePromiseArray = Promise.resolve(elementArray) as unknown as ChainablePromiseArray + + // Ensure `'getElements' in chainableElements` is false while allowing to use `await chainableElement.getElements()` + const runtimeChainablePromiseArray = new Proxy(chainablePromiseArray, { + get(target, prop) { + if (typeof prop === 'string' && /^\d+$/.test(prop)) { + const index = parseInt(prop, 10) + if (index >= length) { + const error = new Error(`Index out of bounds! $$(${selector}) returned only ${length} elements.`) + return new Proxy(Promise.resolve(), { + get(target, prop) { + if (prop === 'then') { + return (resolve: any, reject: any) => reject(error) + } + // Allow resolving methods like 'catch', 'finally' normally from the promise if needed, + // but usually we want any interaction to fail? + // Actually, standard promise methods might be accessed. + // But the user requirements says: `$$('foo')[3].getText()` should return a promise (that rejects). + + // If accessing a property that exists on Promise (like catch, finally, Symbol.toStringTag), maybe we should be careful. + // However, the test expects `el` (the proxy) to be a Promise instance. + // And `el.getText()` to return a promise. + + // If I return a function that returns a rejected promise for everything else: + return () => Promise.reject(error) + } + }) + } + } + if (elementArray && prop in elementArray) { + return elementArray[prop as keyof WebdriverIO.ElementArray] + } + const value = Reflect.get(target, prop) + return typeof value === 'function' ? value.bind(target) : value + } + }) + + return runtimeChainablePromiseArray } export const browser = { @@ -71,4 +197,3 @@ export const browser = { getTitle: vi.spyOn({ getTitle: async () => 'Example Domain' }, 'getTitle'), call(fn: Function) { return fn() }, } satisfies Partial as unknown as WebdriverIO.Browser - diff --git a/test/globals_mock.test.ts b/test/globals_mock.test.ts new file mode 100644 index 000000000..847deb308 --- /dev/null +++ b/test/globals_mock.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest' +import { $, $$ } from '@wdio/globals' +import { notFoundElementFactory } from './__mocks__/@wdio/globals.js' + +vi.mock('@wdio/globals') + +describe('globals mock', () => { + describe($, () => { + it('should return a ChainablePromiseElement', async () => { + const el = $('foo') + + // It behaves like a promise + expect(el).toHaveProperty('then') + expect(el).toBeInstanceOf(Promise) + }) + + it('should resolve to an element', async () => { + const el = await $('foo') + + expect(el.selector).toBe('foo') + // The resolved element should not be the proxy, but the underlying mock + expect(el.getElement).toBeDefined() + }) + + it('should resolve to an element on getElement', async () => { + const el = await $('foo') + const resolvedEl = await el.getElement() + + expect(resolvedEl).toBe(el) + }) + + it('should allow calling getElement on the chainable promise', async () => { + const chainable = $('foo') + + // 'getElement' should not be present in the chainable object if checked via `in` + // based on user request logs: 'getElements' in elements false + expect('getElement' in chainable).toBe(false) + + // But it should be callable + const el = chainable.getElement() + expect(el).toBeInstanceOf(Promise) + + const awaitedEl = await el + expect(awaitedEl.selector).toBe('foo') + expect(awaitedEl.getElement).toBeDefined() + }) + + it('should allow calling methods like isEnabled on the chainable promise', async () => { + const check = $('foo').isEnabled() + expect(check).toBeInstanceOf(Promise) + + const result = await check + expect(result).toBe(true) + }) + + it('should allow chaining simple methods with await', async () => { + const text = await $('foo').getText() + + expect(text).toBe(' Valid Text ') + }) + }) + + describe($$, () => { + it('should return a ChainablePromiseArray', async () => { + const els = $$('foo') + expect(els).toHaveProperty('then') + // @ts-expect-error + expect(typeof els.then).toBe('function') + }) + + it('should resolve to an element array', async () => { + const els = await $$('foo') + expect(Array.isArray(els)).toBe(true) + expect(els).toHaveLength(2) // Default length in mock + expect(els.selector).toBe('foo') + }) + + it('should returns ElementArray on getElements', async () => { + const els = await $$('foo') + + expect(await els.getElements()).toEqual(els) + }) + + it('should allow calling getElements on the chainable promise', async () => { + const chainable = $$('foo') + // 'getElements' should not be present in the chainable object if checked via `in` + expect('getElements' in chainable).toBe(false) + + // But it should be callable + const els = await chainable.getElements() + expect(els).toHaveLength(2) // Default length + }) + + it('should allow iterating if awaited', async () => { + const els = await $$('foo') + // map is available on the resolved array + const selectors = els.map(el => el.selector) + expect(selectors).toEqual(['foo', 'foo']) + }) + + it('should allow calling methods like isEnabled on elements of chainable promise', async () => { + const check = $$('foo')[0].isEnabled() + expect(check).toBeInstanceOf(Promise) + + const result = await check + expect(result).toBe(true) + }) + + it('should allow chaining simple methods with await', async () => { + const text = await $$('foo')[0].getText() + + expect(text).toBe(' Valid Text ') + }) + + it('should returns ElementArray on getElements', async () => { + const els = await $$('foo') + + expect(await els.getElements()).toEqual(els) + }) + + it('should return a promise-like object when accessing index out of bounds', () => { + const el = $$('foo')[3] + // It shouldn't throw synchronously + expect(el).toBeDefined() + expect(el).toBeInstanceOf(Promise) + + // Methods should return a Promise + const getEl = el.getElement() + expect(getEl).toBeInstanceOf(Promise) + // catch unhandled rejection to avoid warnings + getEl.catch(() => {}) + + const getText = el.getText() + expect(getText).toBeInstanceOf(Promise) + // catch unhandled rejection to avoid warnings + getText.catch(() => {}) + }) + + it('should throw "Index out of bounds" when awaiting index out of bounds', async () => { + await expect(async () => await $$('foo')[3]).rejects.toThrow('Index out of bounds! $$(foo) returned only 2 elements.') + await expect(async () => await $$('foo')[3].getElement()).rejects.toThrow('Index out of bounds! $$(foo) returned only 2 elements.') + await expect(async () => await $$('foo')[3].getText()).rejects.toThrow('Index out of bounds! $$(foo) returned only 2 elements.') + }) + }) + + describe('notFoundElementFactory', () => { + it('should return false for isExisting', async () => { + const el = notFoundElementFactory('not-found') + expect(await el.isExisting()).toBe(false) + }) + + it('should resolve to itself when calling getElement', async () => { + const el = notFoundElementFactory('not-found') + expect(await el.getElement()).toBe(el) + }) + + it('should throw error on method calls', async () => { + const el = notFoundElementFactory('not-found') + expect(() => el.click()).toThrow("Can't call click on element with selector not-found because element wasn't found") + }) + + it('should throw error when awaiting a method call (sync throw)', async () => { + const el = notFoundElementFactory('not-found') + expect(() => el.getText()).toThrow("Can't call getText on element with selector not-found because element wasn't found") + }) + }) +}) diff --git a/test/matchers/element/toHaveText.test.ts b/test/matchers/element/toHaveText.test.ts index 92aa72a29..8b31f3430 100755 --- a/test/matchers/element/toHaveText.test.ts +++ b/test/matchers/element/toHaveText.test.ts @@ -1,382 +1,532 @@ import { $, $$ } from '@wdio/globals' import { beforeEach, describe, expect, test, vi } from 'vitest' - -import { getExpectMessage, getReceived, getExpected } from '../../__fixtures__/utils.js' import { toHaveText } from '../../../src/matchers/element/toHaveText.js' import type { ChainablePromiseArray } from 'webdriverio' +import { notFoundElementFactory } from '../../__mocks__/@wdio/globals.js' vi.mock('@wdio/globals') -describe('toHaveText', () => { - describe('when receiving an element array', () => { - let els: ChainablePromiseArray +describe(toHaveText, async () => { + let thisContext: { toHaveText: typeof toHaveText; isNot?: boolean } + let thisNotContext: { toHaveText: typeof toHaveText; isNot: true } + + beforeEach(() => { + thisContext = { toHaveText } + thisNotContext = { toHaveText, isNot: true } + }) + + describe.for([ + { element: await $('sel'), title: 'awaited ChainablePromiseElement' }, + { element: await $('sel').getElement(), title: 'awaited getElement of ChainablePromiseElement (e.g. WebdriverIO.Element)' }, + { element: $('sel'), title: 'non-awaited of ChainablePromiseElement' } + ])('given a single element when $title', ({ element, title }) => { + let el: ChainablePromiseElement | WebdriverIO.Element + + let selectorName = '$(`sel`)' + if (title.includes('non-awaited')) {selectorName = ''} // Bug to fix beforeEach(async () => { - els = await $$('parent') + el = element + vi.mocked(el.getText).mockResolvedValue('WebdriverIO') + }) + + test('wait for success', async () => { + vi.mocked(el.getText).mockResolvedValueOnce('').mockResolvedValueOnce('').mockResolvedValueOnce('webdriverio') + const beforeAssertion = vi.fn() + const afterAssertion = vi.fn() - const el1: ChainablePromiseElement = await $('sel') - el1.getText = vi.fn().mockResolvedValue('WebdriverIO') + const result = await thisContext.toHaveText(el, 'WebdriverIO', { ignoreCase: true, beforeAssertion, afterAssertion }) + + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(3) + expect(beforeAssertion).toBeCalledWith({ + matcherName: 'toHaveText', + expectedValue: 'WebdriverIO', + options: { ignoreCase: true, beforeAssertion, afterAssertion } + }) + expect(afterAssertion).toBeCalledWith({ + matcherName: 'toHaveText', + expectedValue: 'WebdriverIO', + options: { ignoreCase: true, beforeAssertion, afterAssertion }, + result + }) + }) - const el2: ChainablePromiseElement = await $('dev') - el2.getText = vi.fn().mockResolvedValue('Get Started') + test('wait but error', async () => { + vi.mocked(el.getText).mockRejectedValue(new Error('some error')) - els[0] = el1 - els[1] = el2 + await expect(() => thisContext.toHaveText(el, 'WebdriverIO', { ignoreCase: true, wait: 0 })) + .rejects.toThrow('some error') }) - test('should return true if the received element array matches the expected text array', async () => { - const result = await toHaveText.bind({})(els, ['WebdriverIO', 'Get Started']) + test('success and trim actual text by default', async () => { + vi.mocked(el.getText).mockResolvedValue(' WebdriverIO ') + + const result = await thisContext.toHaveText(el, 'WebdriverIO', { wait: 0 }) + expect(result.pass).toBe(true) }) - test('should return true if the received element array matches the expected text array & ignoreCase', async () => { - const result = await toHaveText.bind({})(els, ['webdriverio', 'get started'], { ignoreCase: true }) + test('success on the first attempt', async () => { + const result = await thisContext.toHaveText(el, 'WebdriverIO', { ignoreCase: true, wait: 0 }) + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) }) - test('should return false if the received element array does not match the expected text array', async () => { - const result = await toHaveText.bind({})(els, ['webdriverio', 'get started']) + test('no wait - failure', async () => { + vi.mocked(el.getText).mockResolvedValue('Not WebdriverIO') + + const result = await thisContext.toHaveText(el, 'WebdriverIO', { wait: 0 }) + expect(result.pass).toBe(false) + expect(el.getText).toHaveBeenCalledTimes(1) }) - test('should return true if the expected message shows correctly', async () => { - const result = await toHaveText.bind({})(els, ['webdriverio', 'get started'], { message: 'Test' }) - expect(getExpectMessage(result.message())).toContain('Test') + test('no wait - success', async () => { + const result = await thisContext.toHaveText(el, 'WebdriverIO', { wait: 0 }) + + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) }) - }) - test('wait for success', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValueOnce('').mockResolvedValueOnce('').mockResolvedValueOnce('webdriverio') - const beforeAssertion = vi.fn() - const afterAssertion = vi.fn() + test('not - failure - pass should be true', async () => { + const result = await thisNotContext.toHaveText(el, 'WebdriverIO', { wait: 0 }) - const result = await toHaveText.call({}, el, 'WebdriverIO', { ignoreCase: true, beforeAssertion, afterAssertion }) + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(result.message()).toEqual(`\ +Expect ${selectorName} not to have text - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(3) - expect(beforeAssertion).toBeCalledWith({ - matcherName: 'toHaveText', - expectedValue: 'WebdriverIO', - options: { ignoreCase: true, beforeAssertion, afterAssertion } - }) - expect(afterAssertion).toBeCalledWith({ - matcherName: 'toHaveText', - expectedValue: 'WebdriverIO', - options: { ignoreCase: true, beforeAssertion, afterAssertion }, - result +Expected [not]: "WebdriverIO" +Received : "WebdriverIO"` + ) }) - }) - test('wait but failure', async () => { - const el = await $('sel') - el.getText = vi.fn().mockRejectedValue(new Error('some error')) + test('not, with no trim - failure - pass should be true', async () => { + vi.mocked(el.getText).mockResolvedValue(' WebdriverIO ') - await expect(() => toHaveText.call({}, el, 'WebdriverIO', { ignoreCase: true })) - .rejects.toThrow('some error') - }) + const result = await thisNotContext.toHaveText(el, ' WebdriverIO ', { trim: false, wait: 0 }) - test('success on the first attempt', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` + expect(result.message()).toEqual(`\ +Expect ${selectorName} not to have text - const result = await toHaveText.call({}, el, 'WebdriverIO', { ignoreCase: true }) - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) - }) +Expected [not]: " WebdriverIO " +Received : " WebdriverIO "` + ) + }) - test('no wait - failure', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('webdriverio') + test('not - success - pass should be false', async () => { + const result = await thisNotContext.toHaveText(el, 'not WebdriverIO', { wait: 0 }) - const result = await toHaveText.call({}, el, 'WebdriverIO', { wait: 0 }) + expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` + }) - expect(result.pass).toBe(false) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + test('should return true if texts strictly match without trimming', async () => { + const result = await thisContext.toHaveText(el, 'WebdriverIO', { trim: false, wait: 0 }) - test('no wait - success', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + }) - const result = await toHaveText.call({}, el, 'WebdriverIO', { wait: 0 }) + test("should return false if texts don't match when trimming is disabled", async () => { + const result = await thisContext.toHaveText(el, 'foobar', { trim: false, wait: 0 }) + expect(result.pass).toBe(false) + }) - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + test('should return true if actual text + single replacer matches the expected text', async () => { + const result = await thisContext.toHaveText(el, 'BrowserdriverIO', { wait: 0, replace: ['Web', 'Browser'] }) - test('not - failure - pass should be true', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + }) - const result = await toHaveText.call({ isNot: true }, el, 'WebdriverIO', { wait: 0 }) + test('should return true if actual text + replace (string) matches the expected text', async () => { + const result = await thisContext.toHaveText(el, 'BrowserdriverIO', { wait: 0, replace: [['Web', 'Browser']] }) - expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` - expect(result.message()).toEqual(`\ -Expect $(\`sel\`) not to have text + expect(result.pass).toBe(true) + }) -Expected [not]: "WebdriverIO" -Received : "WebdriverIO"` - ) - }) + test('should return true if actual text + replace (regex) matches the expected text', async () => { + const result = await thisContext.toHaveText(el, 'BrowserdriverIO', { wait: 0, replace: [[/Web/, 'Browser']] }) - test('not - success - pass should be false', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + }) - const result = await toHaveText.call({ isNot: true }, el, 'not WebdriverIO', { wait: 0 }) + test('should return true if actual text starts with expected text', async () => { + const result = await thisContext.toHaveText(el, 'Web', { wait: 0, atStart: true }) - expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` - }) + expect(result.pass).toBe(true) + }) - test('not with no trim - failure - pass should be true', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue(' WebdriverIO ') + test('should return true if actual text ends with expected text', async () => { + const result = await thisContext.toHaveText(el, 'IO', { wait: 0, atEnd: true }) - const result = await toHaveText.call({ isNot: true }, el, ' WebdriverIO ', { trim: false, wait: 0 }) + expect(result.pass).toBe(true) + }) - expect(result.pass).toBe(true) // failure, boolean is inverted later because of `.not` - expect(result.message()).toEqual(`\ -Expect $(\`sel\`) not to have text + test('should return true if actual text contains the expected text at the given index', async () => { + const result = await thisContext.toHaveText(el, 'iverIO', { wait: 0, atIndex: 5 }) -Expected [not]: " WebdriverIO " -Received : " WebdriverIO "` - ) - }) + expect(result.pass).toBe(true) + }) - test('not - success - pass should be false', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + test('message', async () => { + vi.mocked(el.getText).mockResolvedValue('') - const result = await toHaveText.call({ isNot: true }, el, 'not WebdriverIO', { wait: 0 }) + const result = await thisContext.toHaveText(el, 'WebdriverIO', { wait: 0 }) - expect(result.pass).toBe(false) // success, boolean is inverted later because of `.not` - }) + expect(result.message()).toEqual(`\ +Expect ${selectorName} to have text - test('should return true if texts match', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') +Expected: "WebdriverIO" +Received: ""` + ) + }) - const result = await toHaveText.bind({})(el, 'WebdriverIO', { wait: 1 }) - expect(result.pass).toBe(true) - }) + test('success if one of the values in the array matches with text and ignoreCase', async () => { - test('should return true if actual text + single replacer matches the expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + const result = await thisContext.toHaveText(el, ['WDIO', 'Webdriverio'], { wait: 0, ignoreCase: true }) + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) + }) - const result = await toHaveText.bind({})(el, 'BrowserdriverIO', { replace: ['Web', 'Browser'] }) + test('success if one of the values in the array matches with text and trim', async () => { - expect(result.pass).toBe(true) - }) + vi.mocked(el.getText).mockResolvedValue(' WebdriverIO ') - test('should return true if actual text + replace (string) matches the expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + const result = await thisContext.toHaveText(el, ['WDIO', 'WebdriverIO', 'toto'], { wait: 0, trim: true }) - const result = await toHaveText.bind({})(el, 'BrowserdriverIO', { replace: [['Web', 'Browser']] }) + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) + }) - expect(result.pass).toBe(true) - }) + test('success if one of the values in the array matches with text and replace (string)', async () => { + const result = await thisContext.toHaveText(el, ['WDIO', 'BrowserdriverIO', 'toto'], { replace: [['Web', 'Browser']] }) - test('should return true if actual text + replace (regex) matches the expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) + }) - const result = await toHaveText.bind({})(el, 'BrowserdriverIO', { replace: [[/Web/, 'Browser']] }) + test('success if one of the values in the array matches with text and replace (regex)', async () => { - expect(result.pass).toBe(true) - }) + const result = await thisContext.toHaveText(el, ['WDIO', 'BrowserdriverIO', 'toto'], { replace: [[/Web/g, 'Browser']] }) - test('should return true if actual text starts with expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) + }) - const result = await toHaveText.bind({})(el, 'Web', { atStart: true }) + test('success if one of the values in the array matches with text and multiple replacers and one of the replacers is a function', async () => { + const result = await thisContext.toHaveText(el, ['WDIO', 'browserdriverio', 'toto'], { + replace: [ + [/Web/g, 'Browser'], + [/[A-Z]/g, (match: string) => match.toLowerCase()], + ], + }) - expect(result.pass).toBe(true) - }) + expect(result.pass).toBe(true) + expect(el.getText).toHaveBeenCalledTimes(1) + }) - test('should return true if actual text ends with expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + test('failure if one of the values in the array does not match with text', async () => { + const result = await thisContext.toHaveText(el, ['WDIO', 'Webdriverio'], { wait: 0 }) - const result = await toHaveText.bind({})(el, 'IO', { atEnd: true }) + expect(result.pass).toBe(false) + expect(el.getText).toHaveBeenCalledTimes(1) + }) - expect(result.pass).toBe(true) - }) + test('should return true if actual text contains the expected text', async () => { + const result = await thisContext.toHaveText(el, expect.stringContaining('iverIO'), {}) - test('should return true if actual text contains the expected text at the given index', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + }) - const result = await toHaveText.bind({})(el, 'iverIO', { atIndex: 5 }) + test('should return false if actual text does not contain the expected text', async () => { + const result = await thisContext.toHaveText(el, expect.stringContaining('WDIO'), { wait: 0 }) - expect(result.pass).toBe(true) - }) + expect(result.pass).toBe(false) + }) - test('message', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('') + test('should return true if actual text contains one of the expected texts', async () => { + const result = await thisContext.toHaveText(el, [expect.stringContaining('iverIO'), expect.stringContaining('WDIO')], {}) - const result = await toHaveText.call({}, el, 'WebdriverIO') + expect(result.pass).toBe(true) + }) - expect(getExpectMessage(result.message())).toContain('to have text') - }) + test('should return false if actual text does not contain the expected texts', async () => { + const result = await thisContext.toHaveText(el, [expect.stringContaining('EXAMPLE'), expect.stringContaining('WDIO')], { wait: 0 }) - test('success if array matches with text and ignoreCase', async () => { - const el = await $('sel') + expect(result.pass).toBe(false) + }) - el.getText = vi.fn().mockResolvedValue('webdriverio') + describe('with RegExp', () => { + beforeEach(async () => { + vi.mocked(el.getText).mockResolvedValue('This is example text') + }) - const result = await toHaveText.call({}, el, ['WDIO', 'Webdriverio'], { ignoreCase: true }) - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + test('success if match', async () => { + const result = await thisContext.toHaveText(el, /ExAmplE/i) - test('success if array matches with text and trim', async () => { - const el = await $('sel') + expect(result.pass).toBe(true) + }) - el.getText = vi.fn().mockResolvedValue(' WebdriverIO ') + test('success if one of the values in the array matches with RegExp', async () => { + const result = await thisContext.toHaveText(el, ['WDIO', /ExAmPlE/i]) - const result = await toHaveText.call({}, el, ['WDIO', 'WebdriverIO', 'toto'], { trim: true }) + expect(result.pass).toBe(true) + }) - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + test('success if one of the values in the array matches with text', async () => { + const result = await thisContext.toHaveText(el, ['This is example text', /Webdriver/i]) - test('success if array matches with text and replace (string)', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(true) + }) - const result = await toHaveText.call({}, el, ['WDIO', 'BrowserdriverIO', 'toto'], { replace: [['Web', 'Browser']] }) + test('success if one of the values in the array matches with text and ignoreCase', async () => { + const result = await thisContext.toHaveText(el, ['ThIs Is ExAmPlE tExT', /Webdriver/i], { + ignoreCase: true, + }) - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + expect(result.pass).toBe(true) + }) - test('success if array matches with text and replace (regex)', async () => { - const el = await $('sel') + test('failure if no match', async () => { + const result = await thisContext.toHaveText(el, /Webdriver/i, { wait: 0 }) - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(false) + // TODO drepvost verify if we should see array as received value + expect(result.message()).toEqual(`\ +Expect ${selectorName} to have text - const result = await toHaveText.call({}, el, ['WDIO', 'BrowserdriverIO', 'toto'], { replace: [[/Web/g, 'Browser']] }) +Expected: /Webdriver/i +Received: "This is example text"` + ) + }) - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + test('failure if one of the values in the array does not match with text', async () => { + const result = await thisContext.toHaveText(el, ['WDIO', /Webdriver/i], { wait: 0 }) - test('success if array matches with text and multiple replacers and one of the replacers is a function', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + expect(result.pass).toBe(false) + // TODO drepvost verify if we should see array as received value + expect(result.message()).toEqual(`\ +Expect ${selectorName} to have text - const result = await toHaveText.call({}, el, ['WDIO', 'browserdriverio', 'toto'], { - replace: [ - [/Web/g, 'Browser'], - [/[A-Z]/g, (match: string) => match.toLowerCase()], - ], +Expected: ["WDIO", /Webdriver/i] +Received: "This is example text"` + ) + }) }) - - expect(result.pass).toBe(true) - expect(el.getText).toHaveBeenCalledTimes(1) }) - test('failure if array does not match with text', async () => { - const el = await $('sel') + describe.for([ + { 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[])' }, - el.getText = vi.fn().mockResolvedValue('WebdriverIO') - const result = await toHaveText.call({}, el, ['WDIO', 'Webdriverio'], { wait: 1 }) + // Bug that will be fixed later with $$ support. Throws `Error: Can't call "getText" on element with selector "label", it is not a function` + // { elements: $$('sel'), title: 'non-awaited of ChainablePromiseArray' } + ])('given a multiple elements when $title', ({ elements, title }) => { + let els: ChainablePromiseArray | WebdriverIO.ElementArray //| WebdriverIO.Element[] // Bug that will be fixed later with $$ support - expect(result.pass).toBe(false) - expect(el.getText).toHaveBeenCalledTimes(1) - }) + const selectorName = title.includes('WebdriverIO.Element[]') ? '': '$$(`sel`)' // Bug to fix where with Element[] selector name is empty - test('should return true if actual text contains the expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + beforeEach(async () => { + els = elements as ChainablePromiseArray | WebdriverIO.ElementArray // casting, bug that will be fixed later with $$ support - const result = await toHaveText.bind({})(el, expect.stringContaining('iverIO'), {}) + const awaitedEls = await els + awaitedEls[0] = await $('sel') + awaitedEls[1] = await $('dev') + }) - expect(result.pass).toBe(true) - }) + describe('given single expected values', () => { + beforeEach(async () => { + const awaitedEls = await els + expect(awaitedEls.length).toBe(2) - test('should return false if actual text does not contain the expected text', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + awaitedEls.forEach(el => vi.mocked(el.getText).mockResolvedValue('WebdriverIO')) + }) - const result = await toHaveText.bind({})(el, expect.stringContaining('WDIO'), {}) + test('should return true if the received element array matches the expected text array', async () => { + const result = await thisContext.toHaveText(els, 'WebdriverIO', { wait: 0 }) - expect(result.pass).toBe(false) - }) + expect(result.pass).toBe(true) + }) - test('should return true if actual text contains one of the expected texts', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + test('should return true if the received element array matches the expected text array & ignoreCase', async () => { + const result = await thisContext.toHaveText(els, 'webdriverio', { ignoreCase: true, wait: 0 }) - const result = await toHaveText.bind({})(el, [expect.stringContaining('iverIO'), expect.stringContaining('WDIO')], {}) + expect(result.pass).toBe(true) + }) - expect(result.pass).toBe(true) - }) + test('should return true if actual texts contains space since we trim by default', async () => { + const awaitedEls = await els + vi.mocked(awaitedEls[0].getText).mockResolvedValue(' WebdriverIO ') + vi.mocked(awaitedEls[1].getText).mockResolvedValue(' WebdriverIO ') - test('should return false if actual text does not contain the expected texts', async () => { - const el = await $('sel') - el.getText = vi.fn().mockResolvedValue('WebdriverIO') + const result = await thisContext.toHaveText( els, 'WebdriverIO', { wait: 0 }) - const result = await toHaveText.bind({})(el, [expect.stringContaining('EXAMPLE'), expect.stringContaining('WDIO')], {}) + expect(result.pass).toBe(true) + }) - expect(result.pass).toBe(false) - }) + test('should return false if the received element array does not match the expected text array', async () => { + const result = await thisContext.toHaveText(els, 'webdriverio', { wait: 0 }) - describe('with RegExp', () => { - let el: ChainablePromiseElement + expect(result.pass).toBe(false) + }) - beforeEach(async () => { - el = await $('sel') - el.getText = vi.fn().mockResolvedValue('This is example text') - }) + test('should return false and show custom failure message correctly', async () => { + const result = await thisContext.toHaveText(els, 'webdriverio', { message: 'Test', wait: 0 }) - test('success if match', async () => { - const result = await toHaveText.call({}, el, /ExAmplE/i) + // selectorName is buggy, to be fixed later with $$ support + // Expected vs received is wierd, to be fixed later with $$ support + expect(result.message()).toEqual(`\ +Test +Expect ${selectorName} to have text - expect(result.pass).toBe(true) +- Expected - 1 ++ Received + 2 + + Array [ +- "webdriverio", ++ "WebdriverIO", ++ "WebdriverIO", + ]` + ) + }) + + test('should return false and show a correct custom failure message', async () => { + const result = await thisContext.toHaveText( els, 'webdriverio', { message: 'Test', wait: 0 }) + + expect(result.message()).toMatch(/Test\nExpect .* to have text/) + }) }) - test('success if array matches with RegExp', async () => { - const result = await toHaveText.call({}, el, ['WDIO', /ExAmPlE/i]) + describe('given multiples expected values', () => { + beforeEach(async () => { + const awaitedEls = await els + vi.mocked(awaitedEls[0].getText).mockResolvedValue('WebdriverIO') + vi.mocked(awaitedEls[1].getText).mockResolvedValue('Get Started') + }) - expect(result.pass).toBe(true) + test('should return true if the received elements', async () => { + const result = await thisContext.toHaveText(els, ['WebdriverIO', 'Get Started'], { wait: 0 }) + expect(result.pass).toBe(true) + }) + + test('should return true if actual texts contains space since we trim by default', async () => { + const awaitedEls = await els + vi.mocked(awaitedEls[0].getText).mockResolvedValue(' WebdriverIO ') + vi.mocked(awaitedEls[1].getText).mockResolvedValue(' Get Started ') + + const result = await thisContext.toHaveText( els, ['WebdriverIO', 'Get Started'], { wait: 0 }) + + // For single element we trim by default but not for multiple elements, sounds like a bug + expect(result.pass).toBe(false) + }) + + test('should return true if the received element array matches the expected text array & ignoreCase', async () => { + const result = await thisContext.toHaveText(els, ['webdriverio', 'get started'], { ignoreCase: true, wait: 0 }) + expect(result.pass).toBe(true) + }) + + test('should return false if the received element array does not match the expected text array', async () => { + const result = await thisContext.toHaveText(els, ['webdriverio', 'get started'], { wait: 0 }) + + expect(result.pass).toBe(false) + }) + + test('should return false if the second received element array does not match the second expected text in the array', async () => { + const result = await thisContext.toHaveText(els, ['WebdriverIO', 'get started'], { wait: 0 }) + + expect(result.pass).toBe(false) + // Buggy error message to fix later with $$ support + expect(result.message()).toEqual(`\ +Expect ${selectorName} to have text + +- Expected - 3 ++ Received + 1 + + Array [ +- Array [ + "WebdriverIO", +- "get started", +- ], ++ "Get Started", + ]` + ) + }) + + test('should return false and display proper custom error message', async () => { + const result = await thisContext.toHaveText(els, ['webdriverio', 'get started'], { message: 'Test', wait: 0 }) + + expect(result.pass).toBe(false) + // Buggy error message to fix later with $$ support + expect(result.message()).toEqual(`\ +Test +Expect ${selectorName} to have text + +- Expected - 4 ++ Received + 2 + + Array [ +- Array [ +- "webdriverio", +- "get started", +- ], ++ "WebdriverIO", ++ "Get Started", + ]` + ) + }) + + test('should return false and show a correct custom failure message', async () => { + const result = await thisContext.toHaveText( els, 'webdriverio', { message: 'Test', wait: 0 }) + + expect(result.pass).toBe(false) + expect(result.message()).toMatch(/Test\nExpect .* to have text/) + }) }) + }) - test('success if array matches with text', async () => { - const result = await toHaveText.call({}, el, ['This is example text', /Webdriver/i]) + describe('Edge cases', () => { + test('should have pass false with proper error message when actual is an empty array of elements', async () => { + // @ts-ignore + const result = await thisContext.toHaveText([], 'webdriverio') expect(result.pass).toBe(true) }) - test('success if array matches with text and ignoreCase', async () => { - const result = await toHaveText.call({}, el, ['ThIs Is ExAmPlE tExT', /Webdriver/i], { - ignoreCase: true, - }) + // 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') - expect(result.pass).toBe(true) + await expect(thisContext.toHaveText(element, 'webdriverio')).rejects.toThrow("Can't call getText on element with selector sel because element wasn't found") }) - test('failure if no match', async () => { - const result = await toHaveText.call({}, el, /Webdriver/i) + // TODO view later to handle this case more gracefully + test('given element from out of bound ChainableArray, then it throws error when an element does not exists', async () => { + const element: ChainablePromiseElement = $$('elements')[3] - expect(result.pass).toBe(false) - expect(getExpectMessage(result.message())).toContain('to have text') - expect(getExpected(result.message())).toContain('/Webdriver/i') - expect(getReceived(result.message())).toContain('This is example text') + await expect(thisContext.toHaveText(element, 'webdriverio')).rejects.toThrow('Index out of bounds! $$(elements) returned only 2 elements.') }) - test('failure if array does not match with text', async () => { - const result = await toHaveText.call({}, el, ['WDIO', /Webdriver/i]) + // Throws with wierd and differrent error message! + test.skip.for([ + { actual: undefined, selectorName: 'undefined' }, + { actual: null, selectorName: 'null' }, + { actual: true, selectorName: 'true' }, + { actual: 5, selectorName: '5' }, + { actual: 'test', selectorName: 'test' }, + { actual: Promise.resolve(true), selectorName: 'true' }, + { actual: {}, selectorName: '{}' }, + { actual: ['1', '2'], selectorName: '["1","2"]' }, + ])('should have pass false with proper error message when actual is unsupported type of $actual', async ({ actual, selectorName }) => { + const result = await thisContext.toHaveText(actual as any, 'webdriverio') expect(result.pass).toBe(false) - expect(getExpectMessage(result.message())).toContain('to have text') - expect(getExpected(result.message())).toContain('/Webdriver/i') - expect(getExpected(result.message())).toContain('WDIO') + expect(result.message()).toEqual(`\ +Expect ${selectorName} to have text + +Expected: "webdriverio" +Received: undefined`) }) }) }) diff --git a/test/softAssertions.test.ts b/test/softAssertions.test.ts index 647cde302..3e15341f5 100644 --- a/test/softAssertions.test.ts +++ b/test/softAssertions.test.ts @@ -1,22 +1,84 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' -import { $ } from '@wdio/globals' +import { $, $$ } from '@wdio/globals' +import expectLib from 'expect' import { expect as expectWdio, SoftAssertionService, SoftAssertService } from '../src/index.js' vi.mock('@wdio/globals') describe('Soft Assertions', () => { + + it('should handle promises properly and return a promise when matchers are used with Promises or Elements', async () => { + const softService = SoftAssertService.getInstance() + softService.setCurrentTest('promise-0', 'test name', 'test file') + + expect(expectLib(Promise.resolve(true)).resolves.toBe(true)).toBeInstanceOf(Promise) + expect(expectWdio(Promise.resolve(true)).resolves.toBe(true)).toBeInstanceOf(Promise) + expect(expectWdio(Promise.resolve(true)).resolves.not.toBe(false)).toBeInstanceOf(Promise) + expect(expectWdio.soft(Promise.resolve(true)).resolves.toBe(true)).toBeInstanceOf(Promise) + + const elementToHaveText = expectWdio($('element1')).toHaveText('Valid Text') + expect(elementToHaveText).toBeInstanceOf(Promise) + + // TODO remove await once $$() support is merged + const elementsToHaveText = expectWdio(await $$('elements2')).toHaveText('Valid Text') + expect(elementsToHaveText).toBeInstanceOf(Promise) + + const elementsNotToHaveText = expectWdio(await $$('elements3')).not.toHaveText('Not Valid Text') + expect(elementsNotToHaveText).toBeInstanceOf(Promise) + + await Promise.all([elementToHaveText, elementsToHaveText, elementsNotToHaveText]) + + const elementSoftToHaveText = expectWdio.soft($('element4')).toHaveText('Valid Text') + expect(elementSoftToHaveText).toBeInstanceOf(Promise) + + const elementsSoftToHaveText = expectWdio.soft(await $$('elements5')).toHaveText('Valid Text') + expect(elementsSoftToHaveText).toBeInstanceOf(Promise) + + const elementsSoftNotToHaveText = expectWdio.soft(await $$('elements6')).not.toHaveText('Not Valid Text') + expect(elementsSoftNotToHaveText).toBeInstanceOf(Promise) + + // Ensure all assertions are awaited to avoid conflicts in other tests + await Promise.all([elementSoftToHaveText, elementsSoftToHaveText, elementsSoftNotToHaveText]) + }) + + it('should handle non-promises matchers properly by not using promises', () => { + const softService = SoftAssertService.getInstance() + softService.setCurrentTest('non-promise-1', 'test name', 'test file') + + // Base line on Jest 'expect' library + expect(expectLib(true).toBe(true)).toBeUndefined() + expect(expectLib(true).toBe).toBeInstanceOf(Function) + expect(expectLib(true).toBe(true)).not.toBeInstanceOf(Promise) + expect(expectLib(true).not.toBe(false)).not.toBeInstanceOf(Promise) + + // wdio expect + expect(expectWdio(true).toBe(true)).toBeUndefined() + expect(expectWdio(true).toBe).toBeInstanceOf(Function) + expect(expectWdio(true).toBe(true)).not.toBeInstanceOf(Promise) + expect(expectWdio(true).not.toBe(false)).not.toBeInstanceOf(Promise) + + // wdio expect.soft + expect(expectWdio.soft(true).toBe(true)).toBeUndefined() + expect(expectWdio.soft(true).toBe).toBeInstanceOf(Function) + expect(expectWdio.soft(true).toBe).not.toBeInstanceOf(Promise) + expect(expectWdio.soft(true).not.toBe(false)).not.toBeInstanceOf(Promise) + }) + // Setup a mock element for testing let el: ChainablePromiseElement beforeEach(async () => { el = $('sel') + // We need to mock getText() which is what the toHaveText matcher actually calls - el.getText = vi.fn().mockImplementation(() => 'Actual Text') + vi.mocked(el.getText).mockResolvedValue('Actual Text') + // Clear any soft assertion failures before each test expectWdio.clearSoftFailures() }) - describe('expect.soft', () => { + describe('expect.soft with single element', () => { + it('should not throw immediately on failure', async () => { const softService = SoftAssertService.getInstance() softService.setCurrentTest('test-1', 'test name', 'test file') @@ -27,7 +89,12 @@ describe('Soft Assertions', () => { const failures = expectWdio.getSoftFailures() expect(failures.length).toBe(1) expect(failures[0].matcherName).toBe('toHaveText') - expect(failures[0].error.message).toContain('text') + expect(failures[0].error.message).toEqual(`\ +Expect to have text + +Expected: "Expected Text" +Received: "Actual Text"` + ) }) it('should support chained assertions with .not', async () => { @@ -106,22 +173,88 @@ describe('Soft Assertions', () => { expect(expectWdio.getSoftFailures().length).toBe(0) }) - /** - * TODO: Skipped since soft assertions are currently not supporting basic matchers like toBe or toEqual. To fix one day! - * @see https://github.com/webdriverio/expect-webdriverio/issues/1887 - */ - it.skip('should support basic text matching', async () => { + describe('Basic Matchers Support', () => { + it('should support basic matchers failure without await', () => { + const softService = SoftAssertService.getInstance() + softService.setCurrentTest('test-7', 'test name', 'test file') + + expectWdio.soft('Actual Text').toEqual('!Actual Text') + + const failures = expectWdio.getSoftFailures() + expect(failures.length).toBe(1) + expect(failures[0].matcherName).toBe('toEqual') + }) + + it('should support basic matchers success', async () => { + const softService = SoftAssertService.getInstance() + softService.setCurrentTest('test-8', 'test name', 'test file') + + expectWdio.soft('Actual Text').toEqual('Actual Text') + + const failures = expectWdio.getSoftFailures() + expect(failures.length).toBe(0) + }) + + it('not - should support basic matchers failure without await', async () => { + const softService = SoftAssertService.getInstance() + softService.setCurrentTest('test-9', 'test name', 'test file') + + expectWdio.soft('Actual Text').not.toEqual('Actual Text') + + const failures = expectWdio.getSoftFailures() + expect(failures.length).toBe(1) + expect(failures[0].matcherName).toBe('not.toEqual') + }) + + it('not - should support basic matcher success', async () => { + const softService = SoftAssertService.getInstance() + softService.setCurrentTest('test-10', 'test name', 'test file') + + expectWdio.soft('Actual Text').not.toEqual('Not Actual Text') + + const failures = expectWdio.getSoftFailures() + expect(failures.length).toBe(0) + }) + }) + + }) + + describe('expect.soft with multiple elements', () => { + + let elements: ChainablePromiseArray + + beforeEach(async () => { + elements = $$('sel') + + vi.mocked(elements[0].getText).mockResolvedValue('Actual Text 0') + vi.mocked(elements[1].getText).mockResolvedValue('Actual Text 1') + + expectWdio.clearSoftFailures() + }) + + it('should not throw immediately on failure', async () => { const softService = SoftAssertService.getInstance() - softService.setCurrentTest('test-7', 'test name', 'test file') - const text = await el.getText() + softService.setCurrentTest('multiple-elements-test-1', 'test name', 'test file') - expectWdio.soft(text).toEqual('!Actual Text') + await expectWdio.soft(await elements).toHaveText('Expected Text', { wait: 0 }) + // Verify the failure was recorded const failures = expectWdio.getSoftFailures() expect(failures.length).toBe(1) expect(failures[0].matcherName).toBe('toHaveText') + expect(failures[0].error.message).toEqual(`\ +Expect $$(\`sel\`) to have text + +- Expected - 1 ++ Received + 2 + + Array [ +- "Expected Text", ++ "Actual Text 0", ++ "Actual Text 1", + ]` + ) }) - }) describe('SoftAssertService hooks', () => { @@ -157,11 +290,13 @@ describe('Soft Assertions', () => { describe('Different Matcher Types', () => { beforeEach(async () => { el = $('sel') + // Mock different methods for different matchers - el.getText = vi.fn().mockImplementation(() => 'Actual Text') - el.isDisplayed = vi.fn().mockImplementation(() => false) - el.getAttribute = vi.fn().mockImplementation(() => 'actual-class') - el.isClickable = vi.fn().mockImplementation(() => false) + vi.mocked(el.getText).mockResolvedValue('Actual Text') + vi.mocked(el.isDisplayed).mockResolvedValue(false) + vi.mocked(el.getAttribute).mockResolvedValue('actual-class') + vi.mocked(el.isClickable).mockResolvedValue(false) + expectWdio.clearSoftFailures() }) @@ -245,9 +380,9 @@ describe('Soft Assertions', () => { const softService = SoftAssertService.getInstance() softService.setCurrentTest('concurrent-test', 'concurrent', 'test file') - el.getText = vi.fn().mockImplementation(() => 'Actual Text') - el.isDisplayed = vi.fn().mockImplementation(() => false) - el.isClickable = vi.fn().mockImplementation(() => false) + vi.mocked(el.getText).mockResolvedValue('Actual Text') + vi.mocked(el.isDisplayed).mockResolvedValue(false) + vi.mocked(el.isClickable).mockResolvedValue(false) // Fire multiple assertions rapidly const promises = [ @@ -276,10 +411,7 @@ describe('Soft Assertions', () => { softService.setCurrentTest('error-test', 'error test', 'test file') // Mock a matcher that throws a unique error - const originalMethod = el.getText - el.getText = vi.fn().mockImplementation(() => { - throw new TypeError('Weird browser error') - }) + vi.mocked(el.getText).mockRejectedValue(new TypeError('Weird browser error')) await expectWdio.soft(el).toHaveText('Expected Text') @@ -287,9 +419,6 @@ describe('Soft Assertions', () => { expect(failures.length).toBe(1) expect(failures[0].error).toBeInstanceOf(Error) expect(failures[0].error.message).toContain('Weird browser error') - - // Restore - el.getText = originalMethod }) it('should handle very long error messages', async () => { diff --git a/test/types.test-d.ts b/test/types.test-d.ts new file mode 100644 index 000000000..922bf19c3 --- /dev/null +++ b/test/types.test-d.ts @@ -0,0 +1,62 @@ +import { describe, expect, expectTypeOf, test } from 'vitest' +import { $, $$ } from '@wdio/globals' +import type { Matchers, Inverse } from 'expect' +import expectLib from 'expect' +import { expect as expectWdio } from '../src/index.js' + +describe('Type test', () => { + + describe('Expects + Promise matchers & basic matchers', () => { + + test('Jest "expect" lib type tests as baseline', () => { + // Basic matchers + expectTypeOf(expectLib(true)).toExtend & Inverse>>() + expectTypeOf(expectLib(true).toBe(true)).toBeVoid() + expectTypeOf(expectLib(true).toBe(true)).not.toExtend>() + expectTypeOf(expectLib(Promise.resolve(true)).toBe(expect.any)).toBeVoid() + expectTypeOf(expectLib(Promise.resolve(true)).resolves.toBe(expect.any)).resolves.toBeVoid() + + // element matchers are not available in 'expect' lib + expectTypeOf(expectLib($('element')).toBe(expect.any)).toBeVoid() + expectTypeOf(expectLib($('element'))).not.toHaveProperty('toHaveText') + expectTypeOf(expectLib($$('elements')).toBe(expect.any)).toBeVoid() + expectTypeOf(expectLib($$('elements'))).not.toHaveProperty('toHaveText') + }) + + test('Wdio expect & matchers type tests', () => { + // Basic matchers + expectTypeOf(expectWdio(true)).toExtend & Inverse>>() + expectTypeOf(expectWdio(true).toBe(true)).toBeVoid() + expectTypeOf(expectWdio(true).toBe(true)).not.toExtend>() + expectTypeOf(expectWdio(Promise.resolve(true)).toBe(true)) + expectTypeOf(expectWdio(Promise.resolve(true)).resolves.toBe(true)).resolves.toBeVoid() + + // element matchers + expectTypeOf(expectWdio($('element')).toBe(expect.any)).toBeVoid() + expectTypeOf(expectWdio($('element')).toHaveText('test')).not.toBeVoid() + expectTypeOf(expectWdio($('element')).toHaveText('test')).toExtend>() + expectTypeOf(expectWdio($$('elements')).toBe(expect.any)).toBeVoid() + expectTypeOf(expectWdio($$('elements')).toHaveText('test')).toExtend>() + expectTypeOf(expectWdio($$('elements')).toHaveText('test')).not.toBeVoid() + + }) + + test('Wdio soft expect & matchers type tests', () => { + // Basic matchers + expectTypeOf(expectWdio.soft(true)).toExtend & Inverse>>() + expectTypeOf(expectWdio.soft(true).toBe(true)).toExtend() + expectTypeOf(expectWdio.soft(true).toBe(true)).not.toExtend>() + // TODO to fix one day? When non elements matchers + promise, we should stick to void and not have Promise + //expectTypeOf(expectWdio.soft(Promise.resolve(true)).toBe(expect.any)).toBeVoid() + expectTypeOf(expectWdio.soft(Promise.resolve(true)).resolves.toBe(expect.any)).toExtend>() + + // element matchers + expectTypeOf(expectWdio($('element')).toBe(expect.any)).toBeVoid() + expectTypeOf(expectWdio($('element')).toHaveText('test')).not.toBeVoid() + expectTypeOf(expectWdio($('element')).toHaveText('test')).toExtend>() + expectTypeOf(expectWdio($$('elements')).toBe(expect.any)).toBeVoid() + expectTypeOf(expectWdio($$('elements')).toHaveText('test')).toExtend>() + expectTypeOf(expectWdio($$('elements')).toHaveText('test')).not.toBeVoid() + }) + }) +}) diff --git a/test/utils.test.ts b/test/utils.test.ts index cba27af79..a7ef22c5d 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -210,7 +210,7 @@ describe('utils', () => { test('should return false when condition is not met within wait time', async () => { const condition = vi.fn().mockResolvedValue(false) - const result = await waitUntil(condition, { wait: 200, interval: 50 }) + const result = await waitUntil(condition, { wait: 180, interval: 50 }) expect(result).toBe(false) }) @@ -226,7 +226,7 @@ describe('utils', () => { test('should return false if condition throws but still return false', async () => { const condition = vi.fn().mockRejectedValueOnce(new Error('Always failing')).mockRejectedValueOnce(new Error('Always failing')).mockResolvedValue(false) - const result = await waitUntil(condition, { wait: 200, interval: 50 }) + const result = await waitUntil(condition, { wait: 180, interval: 50 }) expect(result).toBe(false) expect(condition).toBeCalledTimes(4) @@ -239,7 +239,7 @@ describe('utils', () => { test('should throw with wait', async () => { const condition = vi.fn().mockRejectedValue(error) - await expect(() => waitUntil(condition, { wait: 200, interval: 50 })).rejects.toThrowError('failing') + await expect(() => waitUntil(condition, { wait: 180, interval: 50 })).rejects.toThrowError('failing') }) test('should throw with wait 0', async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 7b0bc2e75..1ccb8a232 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,8 @@ export default defineConfig({ '**/node_modules/**' ], testTimeout: 15 * 1000, + clearMocks: true, // clears all mock call histories before each test + restoreMocks: true, // restores the original implementation of spies coverage: { enabled: true, exclude: [ @@ -26,10 +28,10 @@ export default defineConfig({ 'types-checks-filter-out-node_modules.js', ], thresholds: { - lines: 88, - functions: 86, - statements: 88, - branches: 79, + lines: 88.2, + functions: 86.9, + statements: 88.1, + branches: 79.6, } } }