Skip to content

Commit d28f93a

Browse files
authored
Merge pull request #1991 from dprevost-LMI/fix-wrong-browser-element-mocks
chore: Ensure mocks used represented wdio framework reality + bonus
2 parents 9b4a88b + 778fe77 commit d28f93a

8 files changed

Lines changed: 731 additions & 312 deletions

File tree

.github/workflows/test.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,5 @@ jobs:
2121
node-version: ${{ matrix.node-version }}
2222
- name: Install Dependencies
2323
run: npm install --force
24-
- name: Build
25-
run: npm run build
26-
- name: Run Tests
27-
run: npm test
24+
- name: Run All Checks
25+
run: npm run checks:all

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
"build": "run-s clean compile",
5656
"clean": "run-p clean:*",
5757
"clean:build": "rimraf ./lib",
58-
"compile": "tsc --build tsconfig.build.json",
58+
"compile": "run-s compile:*",
59+
"compile:lib": "tsc --build tsconfig.build.json",
60+
"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",
5961
"tsc:root-types": "node types-checks-filter-out-node_modules.js",
6062
"test": "run-s test:*",
6163
"test:tsc": "tsc --project tsconfig.json --noEmit --rootDir .",

src/matchers/element/toHaveText.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async function condition(el: WebdriverIO.Element | WebdriverIO.ElementArray, tex
3838
}
3939

4040
export async function toHaveText(
41-
received: ChainablePromiseElement | ChainablePromiseArray,
41+
received: ChainablePromiseElement | ChainablePromiseArray | WebdriverIO.Element | WebdriverIO.ElementArray,
4242
expectedValue: string | RegExp | WdioAsymmetricMatcher<string> | Array<string | RegExp>,
4343
options: ExpectWebdriverIO.StringOptions = DEFAULT_OPTIONS
4444
) {

test/__mocks__/@wdio/globals.ts

Lines changed: 135 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* This file exist for better typed mock implementation, so that we can follow wdio/globals API updates more easily.
44
*/
55
import { vi } from 'vitest'
6-
import type { ChainablePromiseArray, ChainablePromiseElement } from 'webdriverio'
6+
import type { ChainablePromiseArray, ChainablePromiseElement, ParsedCSSValue } from 'webdriverio'
77

88
import type { RectReturn } from '@wdio/protocols'
99
export type Size = Pick<RectReturn, 'width' | 'height'>
@@ -20,46 +20,151 @@ const getElementMethods = () => ({
2020
getHTML: vi.spyOn({ getHTML: async () => { return '<Html/>' } }, 'getHTML'),
2121
getComputedLabel: vi.spyOn({ getComputedLabel: async () => 'Computed Label' }, 'getComputedLabel'),
2222
getComputedRole: vi.spyOn({ getComputedRole: async () => 'Computed Role' }, 'getComputedRole'),
23+
getAttribute: vi.spyOn({ getAttribute: async (_attr: string) => 'some attribute' }, 'getAttribute'),
24+
getCSSProperty: vi.spyOn({ getCSSProperty: async (_prop: string, _pseudo?: string) =>
25+
({ value: 'colorValue', parsed: {} } satisfies ParsedCSSValue) }, 'getCSSProperty'),
2326
getSize: vi.spyOn({ getSize: async (prop?: 'width' | 'height') => {
2427
if (prop === 'width') { return 100 }
2528
if (prop === 'height') { return 50 }
2629
return { width: 100, height: 50 } satisfies Size
27-
} }, 'getSize') as unknown as WebdriverIO.Element['getSize'],
28-
getAttribute: vi.spyOn({ getAttribute: async (_attr: string) => 'some attribute' }, 'getAttribute'),
30+
} },
31+
// Force wrong size & number typing, fixed by https://github.com/webdriverio/webdriverio/pull/15003
32+
'getSize') as unknown as WebdriverIO.Element['getSize'],
33+
$,
34+
$$,
2935
} satisfies Partial<WebdriverIO.Element>)
3036

31-
function $(_selector: string) {
32-
const element = {
37+
export const elementFactory = (_selector: string, index?: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browser): WebdriverIO.Element => {
38+
const partialElement = {
3339
selector: _selector,
3440
...getElementMethods(),
41+
index,
3542
$,
36-
$$
37-
} satisfies Partial<WebdriverIO.Element> as unknown as WebdriverIO.Element
38-
element.getElement = async () => Promise.resolve(element)
39-
return element as unknown as ChainablePromiseElement
43+
$$,
44+
parent
45+
} satisfies Partial<WebdriverIO.Element>
46+
47+
const element = partialElement as unknown as WebdriverIO.Element
48+
element.getElement = vi.fn().mockResolvedValue(element)
49+
50+
// Note: an element found has element.elementId while a not found has element.error
51+
element.elementId = `${_selector}${index ? '-' + index : ''}`
52+
53+
return element
4054
}
4155

42-
function $$(selector: string) {
43-
const length = (this)?._length || 2
44-
const elements = Array(length).fill(null).map((_, index) => {
45-
const element = {
46-
selector,
47-
index,
48-
...getElementMethods(),
49-
$,
50-
$$
51-
} satisfies Partial<WebdriverIO.Element> as unknown as WebdriverIO.Element
52-
element.getElement = async () => Promise.resolve(element)
53-
return element
54-
}) satisfies WebdriverIO.Element[] as unknown as WebdriverIO.ElementArray
55-
56-
elements.foundWith = '$$'
57-
elements.props = []
58-
elements.props.length = length
59-
elements.selector = selector
60-
elements.getElements = async () => elements
61-
elements.length = length
62-
return elements as unknown as ChainablePromiseArray
56+
export const notFoundElementFactory = (_selector: string, index?: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browser): WebdriverIO.Element => {
57+
const partialElement = {
58+
selector: _selector,
59+
index,
60+
$,
61+
$$,
62+
isExisting: vi.fn().mockResolvedValue(false),
63+
parent
64+
} satisfies Partial<WebdriverIO.Element>
65+
66+
const element = partialElement as unknown as WebdriverIO.Element
67+
68+
// Note: an element found has element.elementId while a not found has element.error
69+
const elementId = `${_selector}${index ? '-' + index : ''}`
70+
const error = (functionName: string) => new Error(`Can't call ${functionName} on element with selector ${elementId} because element wasn't found`)
71+
72+
// Mimic element not found by throwing error on any method call beisde isExisting
73+
const notFoundElement = new Proxy(element, {
74+
get(target, prop) {
75+
if (prop in element) {
76+
const value = element[prop as keyof WebdriverIO.Element]
77+
return value
78+
}
79+
if (['then', 'catch', 'toStringTag'].includes(prop as string) || typeof prop === 'symbol') {
80+
const value = Reflect.get(target, prop)
81+
return typeof value === 'function' ? value.bind(target) : value
82+
}
83+
element.error = error(prop as string)
84+
return () => { throw element.error }
85+
}
86+
})
87+
88+
element.getElement = vi.fn().mockResolvedValue(notFoundElement)
89+
90+
return notFoundElement
91+
}
92+
93+
const $ = vi.fn((_selector: string) => {
94+
const element = elementFactory(_selector)
95+
96+
// Wdio framework does return a Promise-wrapped element, so we need to mimic this behavior
97+
const chainablePromiseElement = Promise.resolve(element) as unknown as ChainablePromiseElement
98+
99+
// Ensure `'getElement' in chainableElement` is false while allowing to use `await chainableElement.getElement()`
100+
const runtimeChainableElement = new Proxy(chainablePromiseElement, {
101+
get(target, prop) {
102+
if (prop in element) {
103+
return element[prop as keyof WebdriverIO.Element]
104+
}
105+
const value = Reflect.get(target, prop)
106+
return typeof value === 'function' ? value.bind(target) : value
107+
}
108+
})
109+
return runtimeChainableElement
110+
})
111+
112+
const $$ = vi.fn((selector: string) => {
113+
const length = (this as any)?._length || 2
114+
return chainableElementArrayFactory(selector, length)
115+
})
116+
117+
export function elementArrayFactory(selector: string, length?: number): WebdriverIO.ElementArray {
118+
const elements: WebdriverIO.Element[] = Array(length).fill(null).map((_, index) => elementFactory(selector, index))
119+
120+
const elementArray = elements as unknown as WebdriverIO.ElementArray
121+
122+
elementArray.foundWith = '$$'
123+
elementArray.props = []
124+
elementArray.selector = selector
125+
elementArray.getElements = vi.fn().mockResolvedValue(elementArray)
126+
elementArray.filter = async <T>(fn: (element: WebdriverIO.Element, index: number, array: T[]) => boolean | Promise<boolean>) => {
127+
const results = await Promise.all(elements.map((el, i) => fn(el, i, elements as unknown as T[])))
128+
return Array.prototype.filter.call(elements, (_, i) => results[i])
129+
}
130+
elementArray.parent = browser
131+
132+
return elementArray
133+
}
134+
135+
export function chainableElementArrayFactory(selector: string, length: number) {
136+
const elementArray = elementArrayFactory(selector, length)
137+
138+
// Wdio framework does return a Promise-wrapped element, so we need to mimic this behavior
139+
const chainablePromiseArray = Promise.resolve(elementArray) as unknown as ChainablePromiseArray
140+
141+
// Ensure `'getElements' in chainableElements` is false while allowing to use `await chainableElement.getElements()`
142+
const runtimeChainablePromiseArray = new Proxy(chainablePromiseArray, {
143+
get(target, prop) {
144+
if (typeof prop === 'string' && /^\d+$/.test(prop)) {
145+
// Simulate index out of bounds error when asking for an element outside the array length
146+
const index = parseInt(prop, 10)
147+
if (index >= length) {
148+
const error = new Error(`Index out of bounds! $$(${selector}) returned only ${length} elements.`)
149+
return new Proxy(Promise.resolve(), {
150+
get(target, prop) {
151+
if (prop === 'then') {
152+
return (resolve: any, reject: any) => reject(error)
153+
}
154+
return () => Promise.reject(error)
155+
}
156+
})
157+
}
158+
}
159+
if (elementArray && prop in elementArray) {
160+
return elementArray[prop as keyof WebdriverIO.ElementArray]
161+
}
162+
const value = Reflect.get(target, prop)
163+
return typeof value === 'function' ? value.bind(target) : value
164+
}
165+
})
166+
167+
return runtimeChainablePromiseArray
63168
}
64169

65170
export const browser = {
@@ -71,4 +176,3 @@ export const browser = {
71176
getTitle: vi.spyOn({ getTitle: async () => 'Example Domain' }, 'getTitle'),
72177
call(fn: Function) { return fn() },
73178
} satisfies Partial<WebdriverIO.Browser> as unknown as WebdriverIO.Browser
74-

test/globals_mock.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { describe, it, expect, vi } from 'vitest'
2+
import { $, $$ } from '@wdio/globals'
3+
import { notFoundElementFactory } from './__mocks__/@wdio/globals.js'
4+
5+
vi.mock('@wdio/globals')
6+
7+
describe('globals mock', () => {
8+
describe($, () => {
9+
it('should return a ChainablePromiseElement', async () => {
10+
const el = $('foo')
11+
12+
// It behaves like a promise
13+
expect(el).toHaveProperty('then')
14+
expect(el).toBeInstanceOf(Promise)
15+
})
16+
17+
it('should resolve to an element', async () => {
18+
const el = await $('foo')
19+
20+
expect(el.selector).toBe('foo')
21+
// The resolved element should not be the proxy, but the underlying mock
22+
expect(el.getElement).toBeDefined()
23+
})
24+
25+
it('should resolve to an element on getElement', async () => {
26+
const el = await $('foo')
27+
const resolvedEl = await el.getElement()
28+
29+
expect(resolvedEl).toBe(el)
30+
})
31+
32+
it('should allow calling getElement on the chainable promise', async () => {
33+
const chainable = $('foo')
34+
35+
// 'getElement' should not be present in the chainable object if checked via `in`
36+
// based on user request logs: 'getElements' in elements false
37+
expect('getElement' in chainable).toBe(false)
38+
39+
// But it should be callable
40+
const el = chainable.getElement()
41+
expect(el).toBeInstanceOf(Promise)
42+
43+
const awaitedEl = await el
44+
expect(awaitedEl.selector).toBe('foo')
45+
expect(awaitedEl.getElement).toBeDefined()
46+
})
47+
48+
it('should allow calling methods like isEnabled on the chainable promise', async () => {
49+
const check = $('foo').isEnabled()
50+
expect(check).toBeInstanceOf(Promise)
51+
52+
const result = await check
53+
expect(result).toBe(true)
54+
})
55+
56+
it('should allow chaining simple methods with await', async () => {
57+
const text = await $('foo').getText()
58+
59+
expect(text).toBe(' Valid Text ')
60+
})
61+
})
62+
63+
describe($$, () => {
64+
it('should return a ChainablePromiseArray', async () => {
65+
const els = $$('foo')
66+
expect(els).toHaveProperty('then')
67+
// @ts-expect-error
68+
expect(typeof els.then).toBe('function')
69+
})
70+
71+
it('should resolve to an element array', async () => {
72+
const els = await $$('foo')
73+
expect(Array.isArray(els)).toBe(true)
74+
expect(els).toHaveLength(2) // Default length in mock
75+
expect(els.selector).toBe('foo')
76+
})
77+
78+
it('should returns ElementArray on getElements', async () => {
79+
const els = await $$('foo')
80+
81+
expect(await els.getElements()).toEqual(els)
82+
})
83+
84+
it('should allow calling getElements on the chainable promise', async () => {
85+
const chainable = $$('foo')
86+
// 'getElements' should not be present in the chainable object if checked via `in`
87+
expect('getElements' in chainable).toBe(false)
88+
89+
// But it should be callable
90+
const els = await chainable.getElements()
91+
expect(els).toHaveLength(2) // Default length
92+
})
93+
94+
it('should allow iterating if awaited', async () => {
95+
const els = await $$('foo')
96+
// map is available on the resolved array
97+
const selectors = els.map(el => el.selector)
98+
expect(selectors).toEqual(['foo', 'foo'])
99+
})
100+
101+
it('should allow calling methods like isEnabled on elements of chainable promise', async () => {
102+
const check = $$('foo')[0].isEnabled()
103+
expect(check).toBeInstanceOf(Promise)
104+
105+
const result = await check
106+
expect(result).toBe(true)
107+
})
108+
109+
it('should allow chaining simple methods with await', async () => {
110+
const text = await $$('foo')[0].getText()
111+
112+
expect(text).toBe(' Valid Text ')
113+
})
114+
115+
it('should returns ElementArray on getElements', async () => {
116+
const els = await $$('foo')
117+
118+
expect(await els.getElements()).toEqual(els)
119+
})
120+
121+
it('should return a promise-like object when accessing index out of bounds', () => {
122+
const el = $$('foo')[3]
123+
// It shouldn't throw synchronously
124+
expect(el).toBeDefined()
125+
expect(el).toBeInstanceOf(Promise)
126+
127+
// Methods should return a Promise
128+
const getEl = el.getElement()
129+
expect(getEl).toBeInstanceOf(Promise)
130+
// catch unhandled rejection to avoid warnings
131+
getEl.catch(() => {})
132+
133+
const getText = el.getText()
134+
expect(getText).toBeInstanceOf(Promise)
135+
// catch unhandled rejection to avoid warnings
136+
getText.catch(() => {})
137+
})
138+
139+
it('should throw "Index out of bounds" when awaiting index out of bounds', async () => {
140+
await expect(async () => await $$('foo')[3]).rejects.toThrow('Index out of bounds! $$(foo) returned only 2 elements.')
141+
await expect(async () => await $$('foo')[3].getElement()).rejects.toThrow('Index out of bounds! $$(foo) returned only 2 elements.')
142+
await expect(async () => await $$('foo')[3].getText()).rejects.toThrow('Index out of bounds! $$(foo) returned only 2 elements.')
143+
})
144+
})
145+
146+
describe('notFoundElementFactory', () => {
147+
it('should return false for isExisting', async () => {
148+
const el = notFoundElementFactory('not-found')
149+
expect(await el.isExisting()).toBe(false)
150+
})
151+
152+
it('should resolve to itself when calling getElement', async () => {
153+
const el = notFoundElementFactory('not-found')
154+
expect(await el.getElement()).toBe(el)
155+
})
156+
157+
it('should throw error on method calls', async () => {
158+
const el = notFoundElementFactory('not-found')
159+
expect(() => el.click()).toThrow("Can't call click on element with selector not-found because element wasn't found")
160+
})
161+
162+
it('should throw error when awaiting a method call (sync throw)', async () => {
163+
const el = notFoundElementFactory('not-found')
164+
expect(() => el.getText()).toThrow("Can't call getText on element with selector not-found because element wasn't found")
165+
})
166+
})
167+
})

0 commit comments

Comments
 (0)