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
6 changes: 5 additions & 1 deletion docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -75,7 +79,7 @@ export const config = {
// ...
services: [
// ...other services
[SoftAssertionService]
[SoftAssertionService, {}]
],
// ...
}
Expand Down
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
64 changes: 36 additions & 28 deletions src/softExpect.ts
Original file line number Diff line number Diff line change
@@ -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 = <T = unknown>(actual: T): ExpectWebdriverIO.Matchers<Promise<void>, T> => {
const createSoftExpect = <T = unknown>(actual: T): ExpectWebdriverIO.Matchers<Promise<void> | void, T> => {
const softService = SoftAssertService.getInstance()

// Use a simple proxy that creates matchers on-demand
return new Proxy({} as ExpectWebdriverIO.Matchers<Promise<void>, T>, {
return new Proxy({} as ExpectWebdriverIO.Matchers<Promise<void> | void, T>, {
get(target, prop) {
const propName = String(prop)

Expand All @@ -23,8 +26,8 @@ const createSoftExpect = <T = unknown>(actual: T): ExpectWebdriverIO.Matchers<Pr
return createSoftChainProxy(actual, propName, softService)
}

// Handle matchers
if (matchers.has(propName)) {
if (isPossibleMatcher(propName)) {
// Support basic & wdio (and more) matchers that start with "to"
return createSoftMatcher(actual, propName, softService)
}

Expand All @@ -38,13 +41,10 @@ const createSoftExpect = <T = unknown>(actual: T): ExpectWebdriverIO.Matchers<Pr
* Creates a soft .not proxy
*/
const createSoftNotProxy = <T>(actual: T, softService: SoftAssertService) => {
return new Proxy({} as ExpectWebdriverIO.Matchers<Promise<void>, T>, {
get(target, prop) {
return new Proxy({} as ExpectWebdriverIO.Matchers<Promise<void> | 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
}
})
}
Expand All @@ -53,13 +53,10 @@ const createSoftNotProxy = <T>(actual: T, softService: SoftAssertService) => {
* Creates a soft chain proxy (resolves/rejects)
*/
const createSoftChainProxy = <T>(actual: T, chainType: string, softService: SoftAssertService) => {
return new Proxy({} as ExpectWebdriverIO.Matchers<Promise<void>, T>, {
get(target, prop) {
return new Proxy({} as ExpectWebdriverIO.Matchers<Promise<void> | 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
}
})
}
Expand All @@ -73,7 +70,7 @@ const createSoftMatcher = <T>(
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
Expand All @@ -87,20 +84,31 @@ const createSoftMatcher = <T>(
expectChain = expectChain.rejects
}

return await ((expectChain as unknown) as Record<string, (...args: unknown[]) => 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
187 changes: 156 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,172 @@ 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,
$,
$$,
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
}

export const notFoundElementFactory = (_selector: string, index?: number, parent: WebdriverIO.Browser | WebdriverIO.Element = browser): WebdriverIO.Element => {
const partialElement = {
selector: _selector,
index,
$,
$$
} satisfies Partial<WebdriverIO.Element> 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<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
}

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
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

// 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 = {
Expand All @@ -71,4 +197,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

Loading