Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down
2 changes: 1 addition & 1 deletion src/matchers/element/toHaveText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> | Array<string | RegExp>,
options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
) {
Expand Down
166 changes: 135 additions & 31 deletions test/__mocks__/@wdio/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RectReturn, 'width' | 'height'>
Expand All @@ -20,46 +20,151 @@ const getElementMethods = () => ({
getHTML: vi.spyOn({ getHTML: async () => { return '<Html/>' } }, '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<WebdriverIO.Element>)

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<WebdriverIO.Element> as unknown as WebdriverIO.Element
element.getElement = async () => Promise.resolve(element)
return element as unknown as ChainablePromiseElement
$$,
parent
} satisfies Partial<WebdriverIO.Element>

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<WebdriverIO.Element> 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<WebdriverIO.Element>

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 <T>(fn: (element: WebdriverIO.Element, index: number, array: T[]) => boolean | Promise<boolean>) => {
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 = {
Expand All @@ -71,4 +176,3 @@ export const browser = {
getTitle: vi.spyOn({ getTitle: async () => 'Example Domain' }, 'getTitle'),
call(fn: Function) { return fn() },
} satisfies Partial<WebdriverIO.Browser> as unknown as WebdriverIO.Browser

167 changes: 167 additions & 0 deletions test/globals_mock.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
})
Loading