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 // ============================================================================ diff --git a/packages/electron-service/src/service.ts b/packages/electron-service/src/service.ts index 80773a836..c5d4da353 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,159 @@ 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, + ]); + + type NativeSlice = { + calls: unknown[][]; + results: Array<{ type: string; value: unknown }>; + __error?: string; + }; + + 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) { + // 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; + }, + items, + { internal: true }, + )) ?? {}; + + 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); + } +} + +/** + * Walk every registered browser-mode mock through a single browser.execute() + * 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, + 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 : ''; + }); + // 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++) { + 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; + }, + ids, + perChannelScripts, + )) as Record | null; + 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 { + __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..a1bfd4e4c 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,234 @@ 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 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); + + 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); + + 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); 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,