From 933f2216f37d13e74684ae607205841734ca7688 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 22 May 2026 20:40:00 +0100 Subject: [PATCH 1/5] feat(native-types): add ElectronMockReadAccessor and batch-sync hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two optional hooks to ElectronMockInstance — __accessor (JSON-serialisable descriptor of how to reach the inner mock) and __applyCalls (local diff-apply, no I/O). The new ElectronMockReadAccessor type discriminates on api / prototype / constructor / browser. Used by electron-service to batch every mock's call-data read into a single CDP round-trip; see #268. Co-Authored-By: Claude Opus 4.7 --- packages/native-types/src/electron.ts | 25 +++++++++++++++++++++++++ packages/native-types/src/index.ts | 1 + 2 files changed, 26 insertions(+) diff --git a/packages/native-types/src/electron.ts b/packages/native-types/src/electron.ts index a01616a88..0b4e4cb71 100644 --- a/packages/native-types/src/electron.ts +++ b/packages/native-types/src/electron.ts @@ -438,8 +438,33 @@ export interface ElectronMockInstance extends Omit { update(): Promise; mock: ElectronMockContext; __isElectronMock: boolean; + /** + * Descriptor used by the service's batched mock-update path to fetch the + * inner mock's call data alongside every other registered mock in a single + * CDP round-trip. Service-internal — not part of the user-facing API. + */ + __accessor?: ElectronMockReadAccessor; + /** + * Apply already-fetched call data from the batched read. No I/O — pure local + * diff against the outer mock. Service-internal — not part of the user-facing API. + */ + __applyCalls?: (data: { + calls: unknown[][]; + results?: Array<{ type: string; value: unknown }>; + invocationCallOrder?: number[]; + }) => void; } +/** + * Tag used by the service's batched read to locate the inner mock inside the + * Electron process. Must be JSON-serialisable (crosses the CDP boundary). + */ +export type ElectronMockReadAccessor = + | { kind: 'api'; apiName: string; funcName: string } + | { kind: 'prototype'; className: string; methodName: string } + | { kind: 'constructor'; className: string } + | { kind: 'browser'; channel: string }; + /** * Type for a mocked Electron class (e.g. `Tray`, `BrowserWindow`). * diff --git a/packages/native-types/src/index.ts b/packages/native-types/src/index.ts index ef71d787c..fd453496c 100644 --- a/packages/native-types/src/index.ts +++ b/packages/native-types/src/index.ts @@ -30,6 +30,7 @@ export type { ElectronInterface, ElectronMock, ElectronMockInstance, + ElectronMockReadAccessor, ElectronServiceAPI, ElectronServiceCapabilities, ElectronServiceGlobalOptions, From d64ff18337ffadd3b0303534b93d1d7b5e47ffa1 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 22 May 2026 20:40:14 +0100 Subject: [PATCH 2/5] refactor(electron-service): expose __accessor + __applyCalls on every mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each mock variant (api, prototype, constructor, browser-mode) now exposes the new accessor descriptor and a local diff-apply method. The existing per-mock update() API is preserved verbatim and now delegates its apply phase to the same helper, so explicit user calls still work end-to-end. No behavior change yet — the scheduler refactor in the next commit is what actually batches reads. Refs #268. Co-Authored-By: Claude Opus 4.7 --- packages/electron-service/src/classMock.ts | 59 +++++++++++------- packages/electron-service/src/mock.ts | 63 +++++++++++++------- packages/electron-service/src/mockFactory.ts | 15 +++++ 3 files changed, 95 insertions(+), 42 deletions(-) diff --git a/packages/electron-service/src/classMock.ts b/packages/electron-service/src/classMock.ts index 96d512c2d..3d0ffc1aa 100644 --- a/packages/electron-service/src/classMock.ts +++ b/packages/electron-service/src/classMock.ts @@ -1,7 +1,7 @@ import { type Mock, fn as vitestFn } from '@wdio/native-spy'; import type { AbstractFn, ElectronClassMock, ElectronFunctionMock, ExecuteOpts } from '@wdio/native-types'; import { createLogger } from '@wdio/native-utils'; -import { buildMockMethods } from './mockFactory.js'; +import { buildMockMethods, type MockApplyData, type MockReadAccessor } from './mockFactory.js'; import mockStore from './mockStore.js'; const log = createLogger('electron-service', 'mock'); @@ -52,6 +52,24 @@ async function createPrototypeMock( browserToUse, }); + const applyCalls = (data: MockApplyData) => { + const calls = data.calls ?? []; + const results = data.results ?? []; + const existingCount = originalMock.calls.length; + if (existingCount < calls.length) { + for (let i = existingCount; i < calls.length; i++) { + (originalMock.calls as unknown[][]).push(calls[i]); + (originalMock.results as { type: string; value: unknown }[]).push( + results[i] ?? { type: 'return', value: undefined }, + ); + (originalMock.invocationCallOrder as number[]).push(originalMock.invocationCallOrder.length); + } + } + }; + + mock.__accessor = { kind: 'prototype', className, methodName } satisfies MockReadAccessor; + mock.__applyCalls = applyCalls; + mock.update = async () => { const syncData = (await browserToUse.electron.execute< { calls: unknown[][]; results: { type: string; value: unknown }[] }, @@ -71,16 +89,7 @@ async function createPrototypeMock( { internal: true }, )) ?? { calls: [], results: [] }; - const existingCount = originalMock.calls.length; - if (existingCount < syncData.calls.length) { - for (let i = existingCount; i < syncData.calls.length; i++) { - (originalMock.calls as unknown[][]).push(syncData.calls[i]); - (originalMock.results as { type: string; value: unknown }[]).push( - syncData.results[i] ?? { type: 'return', value: undefined }, - ); - (originalMock.invocationCallOrder as number[]).push(originalMock.invocationCallOrder.length); - } - } + applyCalls(syncData); return mock; }; @@ -190,6 +199,23 @@ export async function createClassMock( { internal: true }, ); + const applyConstructorCalls = (data: MockApplyData) => { + const calls = data.calls ?? []; + const existingCount = constructorOriginalMock.calls.length; + if (existingCount < calls.length) { + // Load-bearing for diagnosing constructor-mock sync races + log.debug( + `[${className}.__constructor] applying ${calls.length - existingCount} new constructor calls (inner=${calls.length}, outer=${existingCount})`, + ); + for (let i = existingCount; i < calls.length; i++) { + (constructorMock as unknown as (...args: unknown[]) => unknown).apply(constructorMock, calls[i] as unknown[]); + } + } + }; + + constructorMock.__accessor = { kind: 'constructor', className } satisfies MockReadAccessor; + constructorMock.__applyCalls = applyConstructorCalls; + constructorMock.update = async () => { const calls = (await browserToUse.electron.execute( @@ -203,16 +229,7 @@ export async function createClassMock( { internal: true }, )) ?? []; - const existingCount = constructorOriginalMock.calls.length; - if (existingCount < calls.length) { - // Load-bearing for diagnosing constructor-mock sync races - log.debug( - `[${className}.__constructor] mock.update: applying ${calls.length - existingCount} new constructor calls (inner=${calls.length}, outer=${existingCount})`, - ); - for (let i = existingCount; i < calls.length; i++) { - (constructorMock as unknown as (...args: unknown[]) => unknown).apply(constructorMock, calls[i] as unknown[]); - } - } + applyConstructorCalls({ calls }); return constructorMock; }; diff --git a/packages/electron-service/src/mock.ts b/packages/electron-service/src/mock.ts index ba0522ca0..91b3be401 100644 --- a/packages/electron-service/src/mock.ts +++ b/packages/electron-service/src/mock.ts @@ -9,7 +9,7 @@ import type { ExecuteOpts, } from '@wdio/native-types'; import { createLogger } from '@wdio/native-utils'; -import { buildMockMethods } from './mockFactory.js'; +import { buildMockMethods, type MockApplyData, type MockReadAccessor } from './mockFactory.js'; const log = createLogger('electron-service', 'mock'); const browserInterceptor = createIpcInterceptor('electron'); @@ -109,6 +109,27 @@ export async function createMock( { internal: true }, ); + // Local diff-and-apply used by both the batched scheduler and mock.update(). + // No I/O — safe to call synchronously with already-fetched call data. + const applyCalls = (data: MockApplyData) => { + const calls = data.calls ?? []; + // Load-bearing for diagnosing mock-sync races: shows inner/outer call counts + // at the moment new calls are applied so empty-mock.calls assertions can be traced. + if (originalMock.calls.length < calls.length) { + log.debug( + `[${apiName}.${funcName}] applying ${calls.length - originalMock.calls.length} new calls (inner=${calls.length}, outer=${originalMock.calls.length})`, + ); + calls.forEach((call: unknown[], index: number) => { + if (!originalMock.calls[index]) { + mock?.apply(mock, call); + } + }); + } + }; + + mock.__accessor = { kind: 'api', apiName, funcName } satisfies MockReadAccessor; + mock.__applyCalls = applyCalls; + mock.update = async () => { const calls = (await browserToUse.electron.execute( @@ -123,18 +144,7 @@ export async function createMock( { internal: true }, )) ?? []; - // Load-bearing for diagnosing mock-sync races: shows inner/outer call counts - // at the moment update() runs so empty-mock.calls assertions can be traced. - if (originalMock.calls.length < calls.length) { - log.debug( - `[${apiName}.${funcName}] mock.update: applying ${calls.length - originalMock.calls.length} new calls (inner=${calls.length}, outer=${originalMock.calls.length})`, - ); - calls.forEach((call: unknown[], index: number) => { - if (!originalMock.calls[index]) { - mock?.apply(mock, call); - } - }); - } + applyCalls({ calls }); return mock; }; @@ -169,6 +179,8 @@ export async function createMock( wrapperMock.update = mock.update.bind(mock); wrapperMock.__isElectronMock = true; + wrapperMock.__accessor = mock.__accessor; + wrapperMock.__applyCalls = mock.__applyCalls.bind(mock); return wrapperMock; } @@ -205,22 +217,31 @@ export async function createElectronBrowserModeMock( await runInterceptorScript(browser, browserInterceptor.buildRegistrationScript(channel)); - mock.update = async () => { - const raw = await runInterceptorScript(browser, browserInterceptor.buildCallDataReadScript(channel)); - const syncData = browserInterceptor.parseCallData(raw); - + const applyCalls = (data: MockApplyData) => { + const calls = data.calls ?? []; + const results = data.results ?? []; + const invocationCallOrder = data.invocationCallOrder ?? []; (originalMock.calls as unknown[][]).length = 0; (originalMock.results as { type: string; value: unknown }[]).length = 0; (originalMock.invocationCallOrder as number[]).length = 0; - for (let i = 0; i < syncData.calls.length; i++) { - (originalMock.calls as unknown[][]).push(syncData.calls[i]); + for (let i = 0; i < calls.length; i++) { + (originalMock.calls as unknown[][]).push(calls[i]); (originalMock.results as { type: string; value: unknown }[]).push( - syncData.results[i] ?? { type: 'return', value: undefined }, + results[i] ?? { type: 'return', value: undefined }, ); (originalMock.invocationCallOrder as number[]).push( - syncData.invocationCallOrder[i] ?? originalMock.invocationCallOrder.length, + invocationCallOrder[i] ?? originalMock.invocationCallOrder.length, ); } + }; + + mock.__accessor = { kind: 'browser', channel } satisfies MockReadAccessor; + mock.__applyCalls = applyCalls; + + mock.update = async () => { + const raw = await runInterceptorScript(browser, browserInterceptor.buildCallDataReadScript(channel)); + const syncData = browserInterceptor.parseCallData(raw); + applyCalls(syncData); return mock; }; diff --git a/packages/electron-service/src/mockFactory.ts b/packages/electron-service/src/mockFactory.ts index 2f8adc3b8..079d01277 100644 --- a/packages/electron-service/src/mockFactory.ts +++ b/packages/electron-service/src/mockFactory.ts @@ -13,6 +13,21 @@ export type MockAccessor = | { kind: 'api'; apiName: string; funcName: string } | { kind: 'prototype'; className: string; methodName: string }; +/** + * Shape of mock call data delivered to a mock's `__applyCalls()` by the + * batched scheduler. Each mock variant uses what it needs and ignores the + * rest (API mocks ignore results; browser-mode mocks consume invocationCallOrder). + */ +export interface MockApplyData { + calls: unknown[][]; + results?: Array<{ type: string; value: unknown }>; + invocationCallOrder?: number[]; +} + +// Re-export the user-facing accessor type from native-types under the local +// `MockReadAccessor` alias for use across service modules. +export type { ElectronMockReadAccessor as MockReadAccessor } from '@wdio/native-types'; + // ============================================================================ // Restore helper for API-function mocks // ============================================================================ From fc5b02cc5ee6ffb12618eee91c2363e2d61e6a8c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 22 May 2026 20:40:28 +0100 Subject: [PATCH 3/5] perf(electron-service): batch updateAllMocks into a single CDP round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scheduler now splits registered mocks by accessor kind and routes each subset through one round-trip: - Native mocks (api / prototype / constructor) ride a single browser.electron.execute() whose inner script walks every accessor and returns {[mockId]: {calls, results}}. - Browser-mode mocks ride one browser.execute() against window.__wdio_mocks__, with raw payloads post-processed via interceptor.parseCallData() to reconstruct Error markers. Each mock's __applyCalls() distributes its slice locally with no further I/O. Mocks without an accessor (test doubles) fall through to the per-mock update() path. Per-mock mock.update() is unchanged for explicit user calls. Tests: - Updated "should update mocks after overridden element command executes" to assert __applyCalls is invoked (the new sync path). - Added "should batch updates for ≥2 mocks into a single browser.electron.execute call" covering all three native accessor kinds. - Added "should batch updates for ≥2 browser-mode mocks into a single browser.execute call" with parseCallData round-trip assertion. - Added "should split native and browser-mode mocks across exactly two round-trips" proving the routing split fires both transports exactly once. Closes #268. Co-Authored-By: Claude Opus 4.7 --- packages/electron-service/src/service.ts | 168 +++++++++++++++++- .../electron-service/test/service.spec.ts | 151 +++++++++++++++- 2 files changed, 309 insertions(+), 10 deletions(-) diff --git a/packages/electron-service/src/service.ts b/packages/electron-service/src/service.ts index 80773a836..360eec6e0 100644 --- a/packages/electron-service/src/service.ts +++ b/packages/electron-service/src/service.ts @@ -5,6 +5,8 @@ import type { BrowserExtension, ElectronFunctionMock, ElectronInterface, + ElectronMock, + ElectronMockReadAccessor, ElectronServiceGlobalOptions, ElectronType, ExecuteOpts, @@ -105,14 +107,35 @@ class MockUpdateScheduler { log.debug(`Found ${mocks.length} mocks to update`); if (mocks.length === 0) return; + // Split mocks by accessor kind so we can route each subset through the + // right transport in exactly one round-trip: native mocks ride + // browser.electron.execute() over CDP, browser-mode mocks ride a plain + // browser.execute() against window.__wdio_mocks__. Mocks without an + // accessor (legacy test doubles) fall back to per-mock update(). + const native: Array<[string, ElectronMock]> = []; + const browserMode: Array<[string, ElectronMock]> = []; + const legacy: Array<[string, ElectronMock]> = []; + for (const entry of mocks) { + const [, m] = entry; + const acc = (m as { __accessor?: ElectronMockReadAccessor }).__accessor; + if (acc?.kind === 'browser') { + browserMode.push(entry); + } else if (acc && typeof (m as { __applyCalls?: unknown }).__applyCalls === 'function') { + native.push(entry); + } else { + legacy.push(entry); + } + } + try { - await Promise.all( - mocks.map(async ([mockId, mock]) => { - log.debug(`Updating mock: ${mockId}`); - await mock.update(); - log.debug(`Mock update completed: ${mockId}`); + await Promise.all([ + batchUpdateNativeMocks(this.#browser, native), + batchUpdateBrowserModeMocks(this.#browser, browserMode), + ...legacy.map(async ([mockId, m]) => { + log.debug(`Updating legacy mock (no accessor): ${mockId}`); + await m.update?.(); }), - ); + ]); log.debug('All mock updates completed successfully'); } catch (error) { log.warn('Mock update batch failed:', error); @@ -120,6 +143,139 @@ class MockUpdateScheduler { } } +/** + * Walk every registered native mock through a single browser.electron.execute() + * round-trip. The inner script discriminates by accessor kind (api / prototype / + * constructor), returns a map keyed by mockId, and each mock applies its own + * slice locally via __applyCalls (no further I/O). + */ +async function batchUpdateNativeMocks( + browser: WebdriverIO.Browser, + entries: Array<[string, ElectronMock]>, +): Promise { + if (entries.length === 0) return; + const items: Array<[string, ElectronMockReadAccessor]> = entries.map(([id, m]) => [ + id, + (m as { __accessor: ElectronMockReadAccessor }).__accessor, + ]); + + const data = + (await browser.electron.execute< + Record }>, + [Array<[string, ElectronMockReadAccessor]>, ExecuteOpts] + >( + (electron, items) => { + const out: Record }> = {}; + for (const item of items) { + const id = item[0]; + const acc = item[1]; + try { + let target: + | { mock?: { calls?: unknown[][]; results?: Array<{ type: string; value: unknown }> } } + | undefined; + if (acc.kind === 'api') { + const api = electron[acc.apiName as keyof typeof electron] as unknown as + | Record } }> + | undefined; + target = api?.[acc.funcName]; + } else if (acc.kind === 'prototype') { + const cls = electron[acc.className as keyof typeof electron] as unknown as + | { + prototype?: Record< + string, + { mock?: { calls?: unknown[][]; results?: Array<{ type: string; value: unknown }> } } + >; + } + | undefined; + target = cls?.prototype?.[acc.methodName]; + } else if (acc.kind === 'constructor') { + target = electron[acc.className as keyof typeof electron] as unknown as typeof target; + } + if (target?.mock) { + out[id] = { + calls: target.mock.calls ? JSON.parse(JSON.stringify(target.mock.calls)) : [], + results: target.mock.results ? JSON.parse(JSON.stringify(target.mock.results)) : [], + }; + } else { + out[id] = { calls: [], results: [] }; + } + } catch (_e) { + out[id] = { calls: [], results: [] }; + } + } + return out; + }, + items, + { internal: true }, + )) ?? {}; + + for (const [id, m] of entries) { + const slice = data[id] ?? { calls: [], results: [] }; + ( + m as { __applyCalls?: (d: { calls: unknown[][]; results?: Array<{ type: string; value: unknown }> }) => void } + ).__applyCalls?.(slice); + } +} + +/** + * Walk every registered browser-mode mock through a single browser.execute() + * round-trip against window.__wdio_mocks__. Raw call data is run through + * the interceptor's parseCallData() per mock to reconstruct Error markers, + * then handed to __applyCalls. + */ +async function batchUpdateBrowserModeMocks( + browser: WebdriverIO.Browser, + entries: Array<[string, ElectronMock]>, +): Promise { + if (entries.length === 0) return; + const ids = entries.map(([id]) => id); + const channels = entries.map(([, m]) => { + const acc = (m as { __accessor: ElectronMockReadAccessor }).__accessor; + return acc.kind === 'browser' ? acc.channel : ''; + }); + + const script = `(function() { + var ids = ${JSON.stringify(ids)}; + var channels = ${JSON.stringify(channels)}; + var out = {}; + var errorReplacer = function(_k, v) { + if (v instanceof Error) { + return { __wdioError: true, name: v.name, message: v.message, stack: v.stack }; + } + return v; + }; + for (var i = 0; i < channels.length; i++) { + var mockObj = window.__wdio_mocks__ && window.__wdio_mocks__[channels[i]]; + if (!mockObj || !mockObj.mock) { + out[ids[i]] = { calls: [], results: [], invocationCallOrder: [] }; + continue; + } + var m = mockObj.mock; + out[ids[i]] = { + calls: JSON.parse(JSON.stringify(m.calls || [], errorReplacer)), + results: JSON.parse(JSON.stringify(m.results || [], errorReplacer)), + invocationCallOrder: JSON.parse(JSON.stringify(m.invocationCallOrder || [])), + }; + } + return out; +})()`; + const raw = (await browser.execute(`return ${script}`)) as Record | null; + const data = raw && typeof raw === 'object' ? raw : {}; + + for (const [id, m] of entries) { + const parsed = browserInterceptor.parseCallData(data[id]); + ( + m as { + __applyCalls?: (d: { + calls: unknown[][]; + results?: Array<{ type: string; value: unknown }>; + invocationCallOrder?: number[]; + }) => void; + } + ).__applyCalls?.(parsed); + } +} + function getMockUpdateScheduler(browser: WebdriverIO.Browser): MockUpdateScheduler { let scheduler = mockUpdateSchedulers.get(browser); if (!scheduler) { diff --git a/packages/electron-service/test/service.spec.ts b/packages/electron-service/test/service.spec.ts index c7b528722..3e0545f9a 100644 --- a/packages/electron-service/test/service.spec.ts +++ b/packages/electron-service/test/service.spec.ts @@ -8,7 +8,7 @@ import { mock } from '../src/commands/mock.js'; import { mockAll } from '../src/commands/mockAll.js'; import { resetAllMocks } from '../src/commands/resetAllMocks.js'; import { restoreAllMocks } from '../src/commands/restoreAllMocks.js'; -import ElectronWorkerService from '../src/service.js'; +import ElectronWorkerService, { browserModeStoreKey } from '../src/service.js'; import { clearPuppeteerSessions, ensureActiveWindowFocus } from '../src/window.js'; import { mockProcessProperty } from './helpers.js'; @@ -261,8 +261,11 @@ describe('Electron Worker Service', () => { await instance.before({}, [], browser); const storeModule = (await import('../src/mockStore.js')) as any; - const mockObj = { update: vi.fn().mockResolvedValue(undefined) }; - storeModule.default.getMocks.mockReturnValueOnce([['id', mockObj]]); + const mockObj = { + __accessor: { kind: 'api', apiName: 'app', funcName: 'getName' }, + __applyCalls: vi.fn(), + }; + storeModule.default.getMocks.mockReturnValueOnce([['electron.app.getName', mockObj]]); const oc = vi.mocked((browser as any).overwriteCommand); const clickCall = oc.mock.calls.find((c: unknown[]) => c[0] === 'click'); @@ -276,10 +279,150 @@ describe('Electron Worker Service', () => { const original = vi.fn().mockResolvedValue('ok'); await overrideFn?.call({} as unknown as WebdriverIO.Element, original); - expect(mockObj.update).toHaveBeenCalledTimes(1); + // Batched scheduler hands the read-back call data to __applyCalls; this + // proves the override still drives mock sync after the per-mock update() + // path was retired. + expect(mockObj.__applyCalls).toHaveBeenCalledTimes(1); expect(original).toHaveBeenCalled(); }); + it('should batch updates for ≥2 mocks into a single browser.electron.execute call', async () => { + instance = new ElectronWorkerService({}, {}); + await instance.before({}, [], browser); + + const executeMock = vi.mocked(await import('../src/commands/executeCdp.js')).execute; + executeMock.mockClear(); + executeMock.mockResolvedValue({}); + + const storeModule = (await import('../src/mockStore.js')) as any; + const mockA = { + __accessor: { kind: 'api', apiName: 'app', funcName: 'getName' }, + __applyCalls: vi.fn(), + }; + const mockB = { + __accessor: { kind: 'prototype', className: 'Tray', methodName: 'setImage' }, + __applyCalls: vi.fn(), + }; + const mockC = { + __accessor: { kind: 'constructor', className: 'Tray' }, + __applyCalls: vi.fn(), + }; + storeModule.default.getMocks.mockReturnValueOnce([ + ['electron.app.getName', mockA], + ['electron.Tray.setImage', mockB], + ['electron.Tray.__constructor', mockC], + ]); + + const oc = vi.mocked((browser as any).overwriteCommand); + const overrideFn = oc.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as unknown as ( + this: WebdriverIO.Element, + original: (...args: unknown[]) => Promise, + ...args: unknown[] + ) => Promise; + + const original = vi.fn().mockResolvedValue('ok'); + await overrideFn.call({} as unknown as WebdriverIO.Element, original); + + // Acceptance criteria: one CDP round-trip regardless of mock count. + expect(executeMock).toHaveBeenCalledTimes(1); + // Every registered mock still receives its slice via __applyCalls. + expect(mockA.__applyCalls).toHaveBeenCalledTimes(1); + expect(mockB.__applyCalls).toHaveBeenCalledTimes(1); + expect(mockC.__applyCalls).toHaveBeenCalledTimes(1); + }); + + it('should batch updates for ≥2 browser-mode mocks into a single browser.execute call', async () => { + instance = new ElectronWorkerService({}, {}); + await instance.before({}, [], browser); + + const storeModule = (await import('../src/mockStore.js')) as any; + const keyA = browserModeStoreKey(browser, 'ipc-a'); + const keyB = browserModeStoreKey(browser, 'ipc-b'); + const mockA = { + __accessor: { kind: 'browser', channel: 'ipc-a' }, + __applyCalls: vi.fn(), + }; + const mockB = { + __accessor: { kind: 'browser', channel: 'ipc-b' }, + __applyCalls: vi.fn(), + }; + + // browser.execute is the transport for browser-mode batch reads. The + // default `(fn) => fn()` impl from beforeEach would try to invoke our + // return-script string as a function — swap in a resolver that yields a + // parseable {[mockStoreKey]: {calls, results, invocationCallOrder}} map. + const browserExecute = browser.execute as unknown as ReturnType; + browserExecute.mockReset(); + browserExecute.mockResolvedValue({ + [keyA]: { calls: [['x']], results: [{ type: 'return', value: undefined }], invocationCallOrder: [1] }, + [keyB]: { calls: [['y']], results: [{ type: 'return', value: undefined }], invocationCallOrder: [2] }, + }); + storeModule.default.getMocks.mockReturnValueOnce([ + [keyA, mockA], + [keyB, mockB], + ]); + + const oc = vi.mocked((browser as any).overwriteCommand); + const overrideFn = oc.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as unknown as ( + this: WebdriverIO.Element, + original: (...args: unknown[]) => Promise, + ...args: unknown[] + ) => Promise; + + const original = vi.fn().mockResolvedValue('ok'); + await overrideFn.call({} as unknown as WebdriverIO.Element, original); + + expect(browserExecute).toHaveBeenCalledTimes(1); + expect(mockA.__applyCalls).toHaveBeenCalledTimes(1); + expect(mockB.__applyCalls).toHaveBeenCalledTimes(1); + // parseCallData should have round-tripped the calls payload intact. + expect(mockA.__applyCalls.mock.calls[0][0]).toMatchObject({ calls: [['x']] }); + expect(mockB.__applyCalls.mock.calls[0][0]).toMatchObject({ calls: [['y']] }); + }); + + it('should split native and browser-mode mocks across exactly two round-trips', async () => { + instance = new ElectronWorkerService({}, {}); + await instance.before({}, [], browser); + + const executeMock = vi.mocked(await import('../src/commands/executeCdp.js')).execute; + executeMock.mockClear(); + executeMock.mockResolvedValue({}); + + const browserExecute = browser.execute as unknown as ReturnType; + browserExecute.mockReset(); + browserExecute.mockResolvedValue({}); + + const storeModule = (await import('../src/mockStore.js')) as any; + const nativeMock = { + __accessor: { kind: 'api', apiName: 'app', funcName: 'getName' }, + __applyCalls: vi.fn(), + }; + const browserMock = { + __accessor: { kind: 'browser', channel: 'ipc-x' }, + __applyCalls: vi.fn(), + }; + storeModule.default.getMocks.mockReturnValueOnce([ + ['electron.app.getName', nativeMock], + [browserModeStoreKey(browser, 'ipc-x'), browserMock], + ]); + + const oc = vi.mocked((browser as any).overwriteCommand); + const overrideFn = oc.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as unknown as ( + this: WebdriverIO.Element, + original: (...args: unknown[]) => Promise, + ...args: unknown[] + ) => Promise; + + const original = vi.fn().mockResolvedValue('ok'); + await overrideFn.call({} as unknown as WebdriverIO.Element, original); + + // Mixed registration: one CDP call for native, one renderer call for browser-mode. + expect(executeMock).toHaveBeenCalledTimes(1); + expect(browserExecute).toHaveBeenCalledTimes(1); + expect(nativeMock.__applyCalls).toHaveBeenCalledTimes(1); + expect(browserMock.__applyCalls).toHaveBeenCalledTimes(1); + }); + it('should run two scheduler batches when two overrides fire concurrently', async () => { instance = new ElectronWorkerService({}, {}); await instance.before({}, [], browser); From b0192f559f5d9d56f4373a7a2578634b2b40d04c Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 22 May 2026 21:15:30 +0100 Subject: [PATCH 4/5] refactor(electron-service): address Greptile review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Browser-mode batch (P2 #1 + #2): switch batchUpdateBrowserModeMocks from a string-interpolated wrapper script to browser.execute(fn, ids, scripts). Null-byte mockStore keys now ride WebDriver's serialisation boundary, and the per-channel read scripts are sourced from browserInterceptor.buildCallDataReadScript() — same contract per-mock update() uses, so Error serialisation can't drift between the two paths. - Native batch (P2 #3): inner script's per-mock try/catch now emits an __error marker on the slice. The outer code log.warns it before forwarding to __applyCalls so the scheduler doesn't stall, but vacuous test passes from silently-zeroed call data are surfaced. - New unit test asserts the __error marker propagates from the native batch inner script through to __applyCalls. Refs #268. Co-Authored-By: Claude Opus 4.7 --- packages/electron-service/src/service.ts | 72 ++++++++++--------- .../electron-service/test/service.spec.ts | 35 +++++++++ 2 files changed, 73 insertions(+), 34 deletions(-) diff --git a/packages/electron-service/src/service.ts b/packages/electron-service/src/service.ts index 360eec6e0..fc888f3c3 100644 --- a/packages/electron-service/src/service.ts +++ b/packages/electron-service/src/service.ts @@ -159,13 +159,19 @@ async function batchUpdateNativeMocks( (m as { __accessor: ElectronMockReadAccessor }).__accessor, ]); + type NativeSlice = { + calls: unknown[][]; + results: Array<{ type: string; value: unknown }>; + __error?: string; + }; + const data = (await browser.electron.execute< - Record }>, + Record, [Array<[string, ElectronMockReadAccessor]>, ExecuteOpts] >( (electron, items) => { - const out: Record }> = {}; + const out: Record = {}; for (const item of items) { const id = item[0]; const acc = item[1]; @@ -199,8 +205,10 @@ async function batchUpdateNativeMocks( } else { out[id] = { calls: [], results: [] }; } - } catch (_e) { - out[id] = { calls: [], results: [] }; + } catch (e) { + // Surface the error to the WDIO worker — empty calls would + // otherwise let assertions pass vacuously. + out[id] = { calls: [], results: [], __error: e instanceof Error ? e.message : String(e) }; } } return out; @@ -211,6 +219,9 @@ async function batchUpdateNativeMocks( for (const [id, m] of entries) { const slice = data[id] ?? { calls: [], results: [] }; + if (slice.__error) { + log.warn(`Native batch read failed for mock "${id}": ${slice.__error}`); + } ( m as { __applyCalls?: (d: { calls: unknown[][]; results?: Array<{ type: string; value: unknown }> }) => void } ).__applyCalls?.(slice); @@ -219,9 +230,12 @@ async function batchUpdateNativeMocks( /** * Walk every registered browser-mode mock through a single browser.execute() - * round-trip against window.__wdio_mocks__. Raw call data is run through - * the interceptor's parseCallData() per mock to reconstruct Error markers, - * then handed to __applyCalls. + * round-trip against window.__wdio_mocks__. Each channel's read script is + * built by the interceptor (same serialisation contract as per-mock update()), + * then the batch wrapper invokes them inside one round-trip. Ids and scripts + * are passed as WebDriver args so null-byte mockStore keys don't ride a + * source-string boundary. Raw payloads run through parseCallData() per mock + * to reconstruct Error markers, then go to __applyCalls. */ async function batchUpdateBrowserModeMocks( browser: WebdriverIO.Browser, @@ -233,33 +247,23 @@ async function batchUpdateBrowserModeMocks( const acc = (m as { __accessor: ElectronMockReadAccessor }).__accessor; return acc.kind === 'browser' ? acc.channel : ''; }); - - const script = `(function() { - var ids = ${JSON.stringify(ids)}; - var channels = ${JSON.stringify(channels)}; - var out = {}; - var errorReplacer = function(_k, v) { - if (v instanceof Error) { - return { __wdioError: true, name: v.name, message: v.message, stack: v.stack }; - } - return v; - }; - for (var i = 0; i < channels.length; i++) { - var mockObj = window.__wdio_mocks__ && window.__wdio_mocks__[channels[i]]; - if (!mockObj || !mockObj.mock) { - out[ids[i]] = { calls: [], results: [], invocationCallOrder: [] }; - continue; - } - var m = mockObj.mock; - out[ids[i]] = { - calls: JSON.parse(JSON.stringify(m.calls || [], errorReplacer)), - results: JSON.parse(JSON.stringify(m.results || [], errorReplacer)), - invocationCallOrder: JSON.parse(JSON.stringify(m.invocationCallOrder || [])), - }; - } - return out; -})()`; - const raw = (await browser.execute(`return ${script}`)) as Record | null; + // One script per channel — same source the per-mock update() path uses, so + // both paths share a single Error-serialisation contract. + const perChannelScripts = channels.map((ch) => browserInterceptor.buildCallDataReadScript(ch)); + + const raw = (await browser.execute( + (innerIds: string[], innerScripts: string[]) => { + const out: Record = {}; + for (let i = 0; i < innerScripts.length; i++) { + // biome-ignore lint/security/noGlobalEval: interceptor-built script wrapper + const reader = new Function(`return (${innerScripts[i]});`)() as (...a: unknown[]) => unknown; + out[innerIds[i]] = reader(); + } + return out; + }, + ids, + perChannelScripts, + )) as Record | null; const data = raw && typeof raw === 'object' ? raw : {}; for (const [id, m] of entries) { diff --git a/packages/electron-service/test/service.spec.ts b/packages/electron-service/test/service.spec.ts index 3e0545f9a..a465f1e53 100644 --- a/packages/electron-service/test/service.spec.ts +++ b/packages/electron-service/test/service.spec.ts @@ -331,6 +331,41 @@ describe('Electron Worker Service', () => { expect(mockC.__applyCalls).toHaveBeenCalledTimes(1); }); + it('should propagate per-mock __error markers from native batch to __applyCalls', async () => { + instance = new ElectronWorkerService({}, {}); + await instance.before({}, [], browser); + + const executeMock = vi.mocked(await import('../src/commands/executeCdp.js')).execute; + executeMock.mockClear(); + // Simulate the inner script catching a per-mock failure and emitting + // an __error marker. The outer code log.warns it and still forwards the + // slice to __applyCalls so the scheduler doesn't stall — empty calls + // reaching the outer mock are the observable signal that the read failed. + executeMock.mockResolvedValue({ + 'electron.app.getName': { calls: [], results: [], __error: 'boom: app undefined' }, + }); + + const storeModule = (await import('../src/mockStore.js')) as any; + const mockObj = { + __accessor: { kind: 'api', apiName: 'app', funcName: 'getName' }, + __applyCalls: vi.fn(), + }; + storeModule.default.getMocks.mockReturnValueOnce([['electron.app.getName', mockObj]]); + + const oc = vi.mocked((browser as any).overwriteCommand); + const overrideFn = oc.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as unknown as ( + this: WebdriverIO.Element, + original: (...args: unknown[]) => Promise, + ...args: unknown[] + ) => Promise; + await overrideFn.call({} as unknown as WebdriverIO.Element, vi.fn().mockResolvedValue('ok')); + + expect(mockObj.__applyCalls).toHaveBeenCalledTimes(1); + expect(mockObj.__applyCalls).toHaveBeenCalledWith( + expect.objectContaining({ calls: [], __error: 'boom: app undefined' }), + ); + }); + it('should batch updates for ≥2 browser-mode mocks into a single browser.execute call', async () => { instance = new ElectronWorkerService({}, {}); await instance.before({}, [], browser); From 9fe39f401bab19d24ca8483d771005c8a69a3d61 Mon Sep 17 00:00:00 2001 From: Sam Maister Date: Fri, 22 May 2026 21:30:11 +0100 Subject: [PATCH 5/5] refactor(electron-service): isolate per-reader failures in browser-mode batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser-mode batch wrapper's `new Function(...)()` invocation was missing the per-reader try/catch that batchUpdateNativeMocks already has — a single malformed channel script or runtime throw would tank the whole batch instead of being scoped to one mock. Each reader is now wrapped in its own try/catch that emits the same { calls: [], results: [], invocationCallOrder: [], __error } shape used by the native batch. The outer loop log.warns __error before forwarding the slice to __applyCalls, preserving observability without stalling the scheduler. Added a test asserting one bad reader doesn't prevent healthy mocks from receiving their slices. Refs #268. Co-Authored-By: Claude Opus 4.7 --- packages/electron-service/src/service.ts | 22 +++++++-- .../electron-service/test/service.spec.ts | 49 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/packages/electron-service/src/service.ts b/packages/electron-service/src/service.ts index fc888f3c3..c5d4da353 100644 --- a/packages/electron-service/src/service.ts +++ b/packages/electron-service/src/service.ts @@ -255,9 +255,21 @@ async function batchUpdateBrowserModeMocks( (innerIds: string[], innerScripts: string[]) => { const out: Record = {}; for (let i = 0; i < innerScripts.length; i++) { - // biome-ignore lint/security/noGlobalEval: interceptor-built script wrapper - const reader = new Function(`return (${innerScripts[i]});`)() as (...a: unknown[]) => unknown; - out[innerIds[i]] = reader(); + try { + // biome-ignore lint/security/noGlobalEval: interceptor-built script wrapper + const reader = new Function(`return (${innerScripts[i]});`)() as (...a: unknown[]) => unknown; + out[innerIds[i]] = reader(); + } catch (e) { + // Isolate per-reader failures so one bad channel doesn't tank the + // whole batch — mirrors batchUpdateNativeMocks. The outer loop + // surfaces __error via log.warn before forwarding to __applyCalls. + out[innerIds[i]] = { + calls: [], + results: [], + invocationCallOrder: [], + __error: e instanceof Error ? e.message : String(e), + }; + } } return out; }, @@ -267,6 +279,10 @@ async function batchUpdateBrowserModeMocks( const data = raw && typeof raw === 'object' ? raw : {}; for (const [id, m] of entries) { + const slice = data[id] as { __error?: string } | undefined; + if (slice?.__error) { + log.warn(`Browser-mode batch read failed for mock "${id}": ${slice.__error}`); + } const parsed = browserInterceptor.parseCallData(data[id]); ( m as { diff --git a/packages/electron-service/test/service.spec.ts b/packages/electron-service/test/service.spec.ts index a465f1e53..a1bfd4e4c 100644 --- a/packages/electron-service/test/service.spec.ts +++ b/packages/electron-service/test/service.spec.ts @@ -331,6 +331,55 @@ describe('Electron Worker Service', () => { expect(mockC.__applyCalls).toHaveBeenCalledTimes(1); }); + it('should isolate per-reader failures in the browser-mode batch', async () => { + instance = new ElectronWorkerService({}, {}); + await instance.before({}, [], browser); + + const keyA = browserModeStoreKey(browser, 'ipc-a'); + const keyB = browserModeStoreKey(browser, 'ipc-b'); + const mockA = { + __accessor: { kind: 'browser', channel: 'ipc-a' }, + __applyCalls: vi.fn(), + }; + const mockB = { + __accessor: { kind: 'browser', channel: 'ipc-b' }, + __applyCalls: vi.fn(), + }; + + // Simulate the inner wrapper's per-reader try/catch: ipc-a returns real + // data while ipc-b's reader threw. The healthy mock should still apply + // its slice, and the failing one should still receive a slice (empty + + // __error stripped by parseCallData) so the scheduler doesn't stall. + const browserExecute = browser.execute as unknown as ReturnType; + browserExecute.mockReset(); + browserExecute.mockResolvedValue({ + [keyA]: { calls: [['x']], results: [{ type: 'return', value: undefined }], invocationCallOrder: [1] }, + [keyB]: { calls: [], results: [], invocationCallOrder: [], __error: 'reader threw' }, + }); + + const storeModule = (await import('../src/mockStore.js')) as any; + storeModule.default.getMocks.mockReturnValueOnce([ + [keyA, mockA], + [keyB, mockB], + ]); + + const oc = vi.mocked((browser as any).overwriteCommand); + const overrideFn = oc.mock.calls.find((c: unknown[]) => c[0] === 'click')?.[1] as unknown as ( + this: WebdriverIO.Element, + original: (...args: unknown[]) => Promise, + ...args: unknown[] + ) => Promise; + await overrideFn.call({} as unknown as WebdriverIO.Element, vi.fn().mockResolvedValue('ok')); + + // Healthy mock still receives its data — one bad reader doesn't tank the batch. + expect(mockA.__applyCalls).toHaveBeenCalledTimes(1); + expect(mockA.__applyCalls.mock.calls[0][0]).toMatchObject({ calls: [['x']] }); + // Failing mock still gets a call (with empty calls from parseCallData) so the + // scheduler completes cleanly. + expect(mockB.__applyCalls).toHaveBeenCalledTimes(1); + expect(mockB.__applyCalls.mock.calls[0][0]).toMatchObject({ calls: [] }); + }); + it('should propagate per-mock __error markers from native batch to __applyCalls', async () => { instance = new ElectronWorkerService({}, {}); await instance.before({}, [], browser);