From fffbaea572438bad67172c87276b40322cb2fd6d Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 12 Aug 2026 02:48:14 +0300 Subject: [PATCH 1/5] fix(wdio-utils): compose element overrides --- packages/wdio-utils/src/monad.ts | 32 ++++- packages/wdio-utils/tests/monad.test.ts | 166 +++++++++++++++++++++++- 2 files changed, 192 insertions(+), 6 deletions(-) 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..644989fefdb 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,107 @@ 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('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 +214,74 @@ 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 + someFunc: (originalCommand: Function, x: number, y: number) => { + calls.push('first') + return originalCommand(x * 2, y) + 1 + } } } } } } - const func = function (x: number, y: number) { return x + y } + const func = function (originalCommand: Function, x: number, y: number) { + calls.push('second') + return originalCommand(x, y * 3) * 2 + } client.overwriteCommand('someFunc', func, true, undefined, instances) - expect(instances.foo.__propertiesObject__.__elementOverrides__.value.someFunc(4, 5)).toBe(9) + const composedCommand = instances.foo.__propertiesObject__.__elementOverrides__.value.someFunc + const baseCommand = (x: number, y: number) => { + calls.push('base') + return x + y + } + + expect(composedCommand(baseCommand, 2, 3)).toBe(28) + expect(calls).toEqual(['second', 'first', '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) + }) + + expect(client.someFunc(1)).toBe('result-3') + expect(calls).toEqual(['second', 'first']) }) it('allows to use custom command wrapper', () => { From 14e3df9ee60bbb4d727d28da730fb97f4c92b9f0 Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 12 Aug 2026 03:23:53 +0300 Subject: [PATCH 2/5] test(wdio-utils): exercise multiremote elements --- packages/wdio-utils/tests/monad.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wdio-utils/tests/monad.test.ts b/packages/wdio-utils/tests/monad.test.ts index 644989fefdb..cc0ce6f5a12 100644 --- a/packages/wdio-utils/tests/monad.test.ts +++ b/packages/wdio-utils/tests/monad.test.ts @@ -239,13 +239,13 @@ describe('monad', () => { } client.overwriteCommand('someFunc', func, true, undefined, instances) - const composedCommand = instances.foo.__propertiesObject__.__elementOverrides__.value.someFunc const baseCommand = (x: number, y: number) => { calls.push('base') return x + y } + const element = createElement(instances.foo.__propertiesObject__.__elementOverrides__, baseCommand) - expect(composedCommand(baseCommand, 2, 3)).toBe(28) + expect(element.someFunc(2, 3)).toBe(28) expect(calls).toEqual(['second', 'first', 'base']) }) From c59316d1ea2f8d77201d5498d1e9ae865f9da545 Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 12 Aug 2026 03:24:27 +0300 Subject: [PATCH 3/5] test(wdio-utils): cover guard registration order --- packages/wdio-utils/tests/monad.test.ts | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/wdio-utils/tests/monad.test.ts b/packages/wdio-utils/tests/monad.test.ts index cc0ce6f5a12..245c05cf398 100644 --- a/packages/wdio-utils/tests/monad.test.ts +++ b/packages/wdio-utils/tests/monad.test.ts @@ -127,6 +127,44 @@ describe('monad', () => { 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[] = [] + 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')) + + await element.someFunc() + 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[] = [] From 939bd159d2779ec3d2bc70432884c036e8eabb05 Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 12 Aug 2026 14:41:27 +0300 Subject: [PATCH 4/5] test: add testplane logger mock --- __mocks__/@testplane/wdio-logger.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 __mocks__/@testplane/wdio-logger.ts 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 From e30a8d93d3a10d814096e91f8081bb13d16460fa Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 12 Aug 2026 15:10:40 +0300 Subject: [PATCH 5/5] test(wdio-utils): harden override coverage --- packages/wdio-utils/tests/monad.test.ts | 61 +++++++++++++++++-------- 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/packages/wdio-utils/tests/monad.test.ts b/packages/wdio-utils/tests/monad.test.ts index 245c05cf398..f16962185c1 100644 --- a/packages/wdio-utils/tests/monad.test.ts +++ b/packages/wdio-utils/tests/monad.test.ts @@ -147,6 +147,10 @@ describe('monad', () => { ])('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() @@ -159,9 +163,22 @@ describe('monad', () => { } register(client, plugin, guard) - const element = createElement(client.__propertiesObject__.__elementOverrides__, async () => calls.push('base')) + 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)) - await element.someFunc() + resolveBase('base result') + await expect(commandResult).resolves.toBe('base result') expect(calls).toEqual(expected) }) @@ -260,31 +277,39 @@ describe('monad', () => { foo: { __propertiesObject__: { __elementOverrides__: { - value: { - someFunc: (originalCommand: Function, x: number, y: number) => { - calls.push('first') - return originalCommand(x * 2, y) + 1 - } - } + value: {} + } + } + }, + bar: { + __propertiesObject__: { + __elementOverrides__: { + value: {} } } } } - const func = function (originalCommand: Function, x: number, y: number) { + 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 - } - - client.overwriteCommand('someFunc', func, true, undefined, instances) - const baseCommand = (x: number, y: number) => { - calls.push('base') + }, true, undefined, instances) + const fooElement = createElement(instances.foo.__propertiesObject__.__elementOverrides__, (x: number, y: number) => { + calls.push('foo base') return x + y - } - const element = createElement(instances.foo.__propertiesObject__.__elementOverrides__, baseCommand) + }) + const barElement = createElement(instances.bar.__propertiesObject__.__elementOverrides__, (x: number, y: number) => { + calls.push('bar base') + return x + y + }) - expect(element.someFunc(2, 3)).toBe(28) - expect(calls).toEqual(['second', 'first', 'base']) + 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', () => {