diff --git a/__mocks__/@testplane/wdio-logger.ts b/__mocks__/@testplane/wdio-logger.ts new file mode 100644 index 00000000000..7c18df102a7 --- /dev/null +++ b/__mocks__/@testplane/wdio-logger.ts @@ -0,0 +1,16 @@ +import { vi } from 'vitest' + +export const logMock = { + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + trace: vi.fn(), + progress: vi.fn() +} +const mock = () => logMock +mock.setLevel = vi.fn() +mock.setLogLevelsConfig = vi.fn() +mock.waitForBuffer = vi.fn() +mock.clearLogger = vi.fn() +export default mock diff --git a/packages/wdio-utils/src/monad.ts b/packages/wdio-utils/src/monad.ts index 85a68efa9c0..cb7fe4bc607 100644 --- a/packages/wdio-utils/src/monad.ts +++ b/packages/wdio-utils/src/monad.ts @@ -13,6 +13,34 @@ interface PropertiesObject { [key: string | symbol]: PropertyDescriptor } +function composeElementOverrides (previousCommand: Function | undefined, nextCommand: Function): Function { + if (!previousCommand) { + return nextCommand + } + + const previousElementCommand = previousCommand + + return function composedElementOverride (this: WebdriverIO.Element, originalCommand: Function, ...args: unknown[]) { + const element = this + + function previousCommandAsOriginal (this: WebdriverIO.Element, ...previousArgs: unknown[]) { + const context = this || element + + function originalForPrevious (this: WebdriverIO.Element, ...originalArgs: unknown[]) { + return originalCommand.apply(this || context, originalArgs) + } + + return previousElementCommand.call(context, originalForPrevious, ...previousArgs) + } + + return nextCommand.call(element, previousCommandAsOriginal, ...args) + } +} + +function setElementOverride (overrides: Record, name: string, command: Function): void { + overrides[name] = composeElementOverrides(overrides[name], command) +} + export default function WebDriver (options: object, modifier?: Function, propertiesObject: PropertiesObject = {}) { /** * In order to allow named scopes for elements we have to propagate that @@ -152,13 +180,13 @@ export default function WebDriver (options: object, modifier?: Function, propert * add command to every multiremote instance */ Object.values(instances).forEach(instance => { - instance.__propertiesObject__.__elementOverrides__.value[name] = customCommand + setElementOverride(instance.__propertiesObject__.__elementOverrides__.value, name, customCommand) }) } else { /** * regular mode */ - this.__propertiesObject__.__elementOverrides__.value[name] = customCommand + setElementOverride(this.__propertiesObject__.__elementOverrides__.value, name, customCommand) } } else if (client[name]) { const origCommand = client[name] diff --git a/packages/wdio-utils/tests/monad.test.ts b/packages/wdio-utils/tests/monad.test.ts index dc1ad08d0ae..f16962185c1 100644 --- a/packages/wdio-utils/tests/monad.test.ts +++ b/packages/wdio-utils/tests/monad.test.ts @@ -15,6 +15,14 @@ beforeEach(() => { const sessionId = 'c5fa4320-07d5-48f5-b7c2-922d4405e17f' +function createElement (elementOverrides: PropertyDescriptor, command: Function) { + return webdriverMonad({}, (element: any) => element, { + scope: { value: 'element' }, + someFunc: { value: command }, + __elementOverrides__: elementOverrides + })(sessionId) +} + describe('monad', () => { it('should be able to initialize client with prototype with commands', () => { const modifier = vi.fn() @@ -93,6 +101,162 @@ describe('monad', () => { expect(client.__propertiesObject__.__elementOverrides__.value.someFunc(2, 3)).toBe(5) }) + it('should compose sequential element command overrides and propagate arguments and return values', async () => { + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId) + const calls: string[] = [] + + client.overwriteCommand('someFunc', async (originalCommand: Function, arg: number) => { + calls.push(`first before: ${arg}`) + const result = await originalCommand(arg * 2) + calls.push('first after') + return result + 1 + }, true) + client.overwriteCommand('someFunc', async (originalCommand: Function, arg: number) => { + calls.push(`second before: ${arg}`) + const result = await originalCommand(arg + 1) + calls.push('second after') + return result * 3 + }, true) + + const element = createElement(client.__propertiesObject__.__elementOverrides__, async (arg: number) => { + calls.push(`base: ${arg}`) + return arg + }) + + await expect(element.someFunc(4)).resolves.toBe(33) + expect(calls).toEqual(['second before: 4', 'first before: 5', 'base: 10', 'first after', 'second after']) + }) + + it.each([ + { + name: 'plugin before guard', + register: (client: any, plugin: Function, guard: Function) => { + client.overwriteCommand('someFunc', plugin, true) + client.overwriteCommand('someFunc', guard, true) + }, + expected: ['guard start', 'plugin', 'base', 'guard wait'] + }, + { + name: 'guard before plugin', + register: (client: any, plugin: Function, guard: Function) => { + client.overwriteCommand('someFunc', guard, true) + client.overwriteCommand('someFunc', plugin, true) + }, + expected: ['plugin', 'guard start', 'base', 'guard wait'] + } + ])('should preserve a navigation guard with $name', async ({ register, expected }) => { + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId) + const calls: string[] = [] + let resolveBase!: (value: string) => void + const baseResult = new Promise((resolve) => { + resolveBase = resolve + }) + const plugin = async (originalCommand: Function) => { + calls.push('plugin') + return originalCommand() + } + const guard = async (originalCommand: Function) => { + calls.push('guard start') + const result = await originalCommand() + calls.push('guard wait') + return result + } + + register(client, plugin, guard) + const element = createElement(client.__propertiesObject__.__elementOverrides__, async () => { + calls.push('base') + return baseResult + }) + let commandSettled = false + const commandResult = element.someFunc().then((result: string) => { + commandSettled = true + return result + }) + + await Promise.resolve() + expect(commandSettled).toBe(false) + expect(calls).toEqual(expected.slice(0, -1)) + + resolveBase('base result') + await expect(commandResult).resolves.toBe('base result') + expect(calls).toEqual(expected) + }) + + it('should propagate errors through sequential element command overrides', async () => { + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId) + const calls: string[] = [] + const error = new Error('boom') + + client.overwriteCommand('someFunc', async (originalCommand: Function) => { + try { + return await originalCommand() + } catch (err) { + calls.push('first caught') + throw err + } + }, true) + client.overwriteCommand('someFunc', async (originalCommand: Function) => { + try { + return await originalCommand() + } catch (err) { + calls.push('second caught') + throw err + } + }, true) + + const element = createElement(client.__propertiesObject__.__elementOverrides__, async () => { + calls.push('base') + throw error + }) + + await expect(element.someFunc()).rejects.toBe(error) + expect(calls).toEqual(['base', 'first caught', 'second caught']) + }) + + it('should preserve explicit context rebinding through sequential element command overrides', () => { + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId) + const reboundElement = { name: 'rebound element' } + let firstContext: unknown + let secondContext: unknown + let baseContext: unknown + + client.overwriteCommand('someFunc', function (this: unknown, originalCommand: Function, arg: number) { + firstContext = this + return originalCommand(arg) + }, true) + client.overwriteCommand('someFunc', function (this: unknown, originalCommand: Function, arg: number) { + secondContext = this + return originalCommand.call(reboundElement, arg) + }, true) + + const element = createElement(client.__propertiesObject__.__elementOverrides__, function (this: unknown, arg: number) { + baseContext = this + return arg + }) + + expect(element.someFunc(123)).toBe(123) + expect(secondContext).toBe(element) + expect(firstContext).toBe(reboundElement) + expect(baseContext).toBe(reboundElement) + }) + + it('should compose three element command overrides in reverse registration order', () => { + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId) + const calls: string[] = [] + + for (const label of ['first', 'second', 'third']) { + client.overwriteCommand('someFunc', (originalCommand: Function) => { + calls.push(label) + return originalCommand() + }, true) + } + + const element = createElement(client.__propertiesObject__.__elementOverrides__, () => calls.push('base')) + + element.someFunc() + expect(calls).toEqual(['third', 'second', 'first', 'base']) + }) + it('should add element commands to the __propertiesObject__ cache in multiremote', () => { const monad = webdriverMonad({}, (client: any) => client, prototype) const client = monad(sessionId) @@ -105,25 +269,82 @@ describe('monad', () => { expect(instances.foo.__propertiesObject__.myCustomElementCommand.value).toBe(func) }) - it('should add element commands for override to the __propertiesObject__.__elementOverrides__ cache in multiremote', () => { + it('should compose element command overrides in multiremote', () => { const monad = webdriverMonad({}, (client: any) => client, { ...prototype }) const client = monad(sessionId) + const calls: string[] = [] const instances = { foo: { __propertiesObject__: { __elementOverrides__: { - value: { - someFunc: (x: number, y: number) => x - y - } + value: {} + } + } + }, + bar: { + __propertiesObject__: { + __elementOverrides__: { + value: {} } } } } - const func = function (x: number, y: number) { return x + y } + client.overwriteCommand('someFunc', function (originalCommand: Function, x: number, y: number) { + calls.push('first') + return originalCommand(x * 2, y) + 1 + }, true, undefined, instances) + client.overwriteCommand('someFunc', function (originalCommand: Function, x: number, y: number) { + calls.push('second') + return originalCommand(x, y * 3) * 2 + }, true, undefined, instances) + const fooElement = createElement(instances.foo.__propertiesObject__.__elementOverrides__, (x: number, y: number) => { + calls.push('foo base') + return x + y + }) + const barElement = createElement(instances.bar.__propertiesObject__.__elementOverrides__, (x: number, y: number) => { + calls.push('bar base') + return x + y + }) + + expect(fooElement.someFunc(2, 3)).toBe(28) + expect(barElement.someFunc(2, 3)).toBe(28) + expect(calls).toEqual(['second', 'first', 'foo base', 'second', 'first', 'bar base']) + }) + + it('should invoke command wrappers once for each composed element override', () => { + const calls: string[] = [] + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId, (commandName: string, commandFn: Function) => { + return function (this: unknown, ...args: unknown[]) { + calls.push(`wrapper: ${commandName}`) + return commandFn.apply(this, args) + } + }) + + client.overwriteCommand('someFunc', (originalCommand: Function) => originalCommand(), true) + client.overwriteCommand('someFunc', (originalCommand: Function) => originalCommand(), true) + + const element = createElement(client.__propertiesObject__.__elementOverrides__, () => calls.push('base')) + + element.someFunc() + expect(calls).toEqual(['wrapper: someFunc', 'wrapper: someFunc', 'base']) + }) + + it('should keep composing sequential browser command overrides', () => { + const client = webdriverMonad({}, (browser: any) => browser, { ...prototype })(sessionId, (commandName: string, commandFn: Function) => commandFn) + const calls: string[] = [] + + client.overwriteCommand('someFunc', (originalCommand: Function, arg: number) => { + calls.push('first') + return originalCommand(arg + 1) + }) + client.overwriteCommand('someFunc', (originalCommand: Function, arg: number) => { + calls.push('second') + return originalCommand(arg + 1) + }) - client.overwriteCommand('someFunc', func, true, undefined, instances) - expect(instances.foo.__propertiesObject__.__elementOverrides__.value.someFunc(4, 5)).toBe(9) + expect(client.someFunc(1)).toBe('result-3') + expect(calls).toEqual(['second', 'first']) }) it('allows to use custom command wrapper', () => {