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/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 82d4450c7..d9aa45186 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/test/__mocks__/@wdio/globals.ts b/test/__mocks__/@wdio/globals.ts index d9f4a3b4a..7b46c68f1 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,151 @@ 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, $, - $$ - } satisfies Partial as unknown as WebdriverIO.Element - element.getElement = async () => Promise.resolve(element) - return element as unknown as ChainablePromiseElement + $$, + 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 } -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 +export const notFoundElementFactory = (_selector: string, index?: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browser): WebdriverIO.Element => { + const partialElement = { + selector: _selector, + index, + $, + $$, + 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 +} + +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 + + 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)) { + // Simulate index out of bounds error when asking for an element outside the array length + 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) + } + 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 +176,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..3f61d7bcf 100644 --- a/test/softAssertions.test.ts +++ b/test/softAssertions.test.ts @@ -10,8 +10,10 @@ describe('Soft Assertions', () => { 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() }) @@ -157,11 +159,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 +249,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 +280,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 +288,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/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, } } }