feat: implement browser-only test mode (Phase 2)#264
Conversation
Mirror Tauri's browser mode for Electron: adds mode='browser' + devServerUrl to ElectronServiceOptions/GlobalOptions, skips binary detection and CDP bridge in launcher, injects ipcRenderer IPC stub into the browser page, and exposes browser.electron.mock(channel) for test-side IPC channel mocking. Also extracts WDIO_MOCK_SETUP_SCRIPT from TauriAdapter into a shared injection.ts constant so both adapters compose from the same browser- side mock factory, and fully implements ElectronAdapter (previously all stubs) with all FrameworkAdapter methods. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…rity - Refactored test cases in `index.spec.ts` to group related tests under descriptive `describe` blocks, improving organization and readability. - Updated test descriptions to follow a consistent "should" format, clarifying expected behaviors for each test case. - Ensured comprehensive coverage of the `serializeHandler`, `buildRegistrationScript`, `buildSetImplementationScript`, `buildWithImplementationScript`, and `parseCallData` methods.
Release Preview — no release
Updated automatically by ReleaseKit |
Greptile SummaryThis PR implements browser-only test mode for the Electron service (Phase 2), mirroring the existing Tauri browser mode: it adds
Confidence Score: 4/5The browser-mode feature is substantial and well-structured; the most impactful issues from the previous review cycle are addressed. The remaining open concern (installCommandOverrides firing in native mode) is acknowledged and deferred to #268. The PR adds a large new execution path with complex multiremote and per-instance lifecycle management. The previous review cycle surfaced many issues — mode detection limited to the first cap, devServerUrl validation per-cap, mockResolvedValue serializing a Promise, mockClear replacing arrays, root-browser URL patching, stale mock re-registration, and injection-error swallowing — and this iteration demonstrates careful, targeted fixes for each one. The installCommandOverrides-in-native-mode regression is the only remaining known defect on the changed path, and the developer has explicitly deferred it to a follow-up PR. packages/electron-service/src/service.ts contains the most complex logic (MockUpdateScheduler, multiremote URL patching, mock store key scheme, concurrent registration gate) and warrants careful review. packages/native-spy/src/interceptor/injection.ts is the shared browser-side mock factory and any bug there affects both Electron and Tauri browser modes.
|
| Filename | Overview |
|---|---|
| packages/electron-service/src/service.ts | Core browser-mode orchestration: initBrowserMode, patchBrowserUrl, getElectronBrowserModeAPI, MockUpdateScheduler, per-instance store keys. Several previously-flagged issues are resolved; residual complexity around multiremote mock ownership remains. |
| packages/electron-service/src/mock.ts | createElectronBrowserModeMock: all async mock methods, implState tracking for __replayBrowserImpl, full update() sync via IPC interceptor scripts. |
| packages/electron-service/src/launcher.ts | Validates uniform mode across all capabilities, validates devServerUrl per-cap, strips Electron binary from chromeOptions, skips CDP/binary setup in browser mode. Previously-flagged first-cap-only issues are fixed. |
| packages/native-spy/src/interceptor/injection.ts | Extracts WDIO_MOCK_SETUP_SCRIPT from TauriAdapter with bug fixes: __wdioType sentinels for resolved/rejected values, in-place array mutation in mockClear, stable _mockSnapshot reference. |
| packages/native-spy/src/interceptor/electron.ts | Full ElectronAdapter implementation: buildBrowserIpcInjectionScript injects ipcRenderer stub with invoke/send/sendSync + no-op on/once/removeListener/removeAllListeners, all other FrameworkAdapter methods implemented. |
| packages/native-spy/src/interceptor/tauri.ts | Migrated to shared WDIO_MOCK_SETUP_SCRIPT; adds event subsystem (wdio_tauri_listeners, wdio_emit_tauri_event, wdio_handle_plugin_event) to support browser-mode emitEvent. Error serialization added to buildCallDataReadScript. |
| packages/tauri-service/src/service.ts | Adds emitEvent to TauriServiceAPI with both browser-mode (via wdio_emit_tauri_event) and native-mode paths; patchBrowserUrl now rethrows on injection failure. |
| packages/native-spy/src/interceptor/syncProtocol.ts | parseCallData now recursively reconstructs Error objects from __wdioError sentinel, matching the new error serialization in buildCallDataReadScript. |
| packages/native-spy/src/mock.ts | mockClear changed from array replacement to in-place mutation (.length = 0) to keep external array references valid after vitest-side clear operations. |
| packages/electron-service/src/mockStore.ts | Adds setMockWithKey (bypasses getMockName key derivation) and deleteMockByRef (reference-equality lookup) for browser-mode instance-scoped keys. |
| packages/native-types/src/electron.ts | Adds mode and devServerUrl fields to ElectronServiceOptions and ElectronServiceGlobalOptions. |
| packages/native-types/src/tauri.ts | Adds TauriEventTarget union type mirroring @tauri-apps/api/event; adds emitEvent to TauriServiceAPI. |
Sequence Diagram
sequenceDiagram
participant Test as Test Runner
participant Launcher as ElectronLaunchService
participant Service as ElectronWorkerService
participant Browser as Chrome (browser mode)
participant Page as App Page
Test->>Launcher: onPrepare(caps)
Launcher->>Launcher: "validate all caps same mode='browser'"
Launcher->>Launcher: validate devServerUrl per-cap
Launcher->>Launcher: "set browserName='chrome', strip binary"
Launcher-->>Test: return (skip CDP/binary setup)
Test->>Service: beforeSession(caps, instance)
Service->>Service: initBrowserMode(browser)
Service->>Browser: browser.url(devServerUrl)
Browser->>Page: navigate
Service->>Browser: browser.execute(injectionScript)
Note over Browser,Page: window.__wdio_spy__, window.__wdio_mocks__,<br/>window.electron.ipcRenderer stub injected
Service->>Browser: patchBrowserUrl() — wraps url() for re-injection
Service->>Browser: installCommandOverrides() — click/setValue triggers mock sync
Service->>Browser: "browser.electron = getElectronBrowserModeAPI()"
Test->>Browser: browser.electron.mock('channel')
Browser->>Page: execute(buildRegistrationScript)
Note over Page: window.__wdio_mocks__['channel'] = spy.fn()
Browser-->>Test: ElectronFunctionMock
Test->>Browser: browser.url('new-page')
Browser->>Page: navigate (wipes window)
Browser->>Page: execute(injectionScript) [re-inject]
Note over Browser: liveness check in mock() detects !isLive
Test->>Browser: browser.electron.mock('channel') [re-register]
Browser->>Page: execute(buildRegistrationScript)
Browser->>Page: __replayBrowserImpl() [restore impl]
Browser->>Browser: existing.mockClear()
Test->>Browser: browser.$('btn').click()
Browser->>Browser: MockUpdateScheduler.schedule()
Browser->>Page: execute(buildCallDataReadScript)
Page-->>Browser: "{ calls, results, invocationCallOrder }"
Browser->>Browser: mock.update() — sync to Node.js side
Reviews (39): Last reviewed commit: "docs(browser-mode): clarify multiremote ..." | Re-trigger Greptile
…tion
Two bugs in getElectronBrowserModeAPI.mock():
1. The bare catch{} swallowed any exception from mockReset(), silently
creating a fresh mock instead of surfacing WebDriver or script errors.
Now only catches the 'No mock registered for' error from getMock().
2. After browser.url() navigation window.__wdio_mocks__ is wiped. When
mock(channel) finds an existing store entry and calls mockReset(), the
inner script silently no-ops because the channel key no longer exists
in the browser. Re-run buildRegistrationScript before mockReset() to
restore the browser-side entry, then reset clears call history on both
sides as expected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…lities Previously only the first Electron capability's mode was inspected, causing incorrect native-vs-browser setup when a multiremote config mixed modes. Now all Electron capabilities are checked; a SevereServiceError is thrown immediately if they disagree, preventing silent misconfiguration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ners Apps commonly call ipcRenderer.on() during module init to register push- event handlers. Without stubs these calls throw 'TypeError: not a function' and crash the page before any test runs. Add no-op stubs for the four listener methods so apps load cleanly; push-event IPC is not interceptable in browser mode but the app can still start. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In browser mode there is no Electron binary and windowHandle is never set, so ensureActiveWindowFocus would call switchToWindow with an undefined handle on every DOM command, throwing "no such window" for every click/setValue. Guard beforeCommand with an early return when mode === 'browser'; installCommandOverrides (mock syncing) is unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mode The previous check only validated the first Electron capability's devServerUrl. In multiremote configs each cap can carry its own URL, so the others were never checked and an invalid URL would reach browser.url() with a confusing WebDriver error instead of the descriptive SevereServiceError. Now every cap is validated independently, falling back to globalOptions.devServerUrl as before. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… validation The previous fix added a validation loop over electronCapsList (filtered by direct cap[CUSTOM_CAPABILITY_NAME] presence) while cap mutation used capsList.flatMap(getElectronCapabilities) — which also unwraps W3C alwaysMatch-wrapped caps. Those nested caps bypassed URL validation entirely and were mutated without a devServerUrl check. Extract all Electron caps once via getElectronCapabilities (consistent with the native-mode path) and use the same set for mode detection, URL validation, and cap mutation. This also eliminates the duplicate flatMap call in native mode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…k collision Two issues in browser-mode service: 1. initBrowserMode threw plain Error for missing devServerUrl; replaced with SevereServiceError so the runner stops cleanly (consistent with the launcher-side validation). 2. In multiremote, getElectronBrowserModeAPI is called once per instance but the mock() path uses a shared mockStore. If instance A registers 'channel' first and instance B's mock() call finds that entry, the old code re-registered on B's page then called mockReset() on A's mock — running inner scripts against A's browser. Fixed with a WeakMap<ElectronFunctionMock, Browser> that records which browser instance owns each browser-mode mock. mock() only reuses a store entry when the owner matches the calling browser; otherwise it creates a fresh mock for the current instance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…stances Updated the ElectronWorkerService to only apply the patchBrowserUrl method when the browser is not in multiremote mode. This change prevents unnecessary URL patching in multiremote configurations, ensuring cleaner handling of browser instances. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… overrides Refactored the createElectronBrowserModeMock function to simplify the handling of mock data by resetting the original mock's calls, results, and invocation order in a single step. Additionally, updated the installCommandOverrides method to accept an optional targetBrowser parameter, allowing for more flexible command overriding across different browser instances. This enhances the overall efficiency and clarity of the ElectronWorkerService implementation.
Updated the README to include new features of Browser Mode, such as the ability to test the renderer in Chrome against a Vite dev server. Added detailed documentation on Browser Mode in the new browser-mode.md file, including setup instructions, IPC mocking, and usage examples. Enhanced the API reference to clarify limitations and provide guidance on using browser-specific methods. This improves overall clarity and usability for developers transitioning to Browser Mode.
…imitations Introduced a comprehensive guide for Browser Mode in a new browser-mode.md file, detailing setup, IPC mocking, and usage examples. Updated the README to highlight the new feature of testing Tauri frontends in Chrome against a Vite dev server. Enhanced the API reference to specify limitations of certain commands in Browser Mode, ensuring clarity for developers. This improves the overall documentation and usability for users transitioning to Browser Mode.
Updated the Electron mock store to include a new method for deleting mocks by reference, improving the management of mock instances. Refactored the createElectronBrowserModeMock function to utilize a unique key for storing mocks, ensuring better isolation and retrieval. Enhanced tests to cover the new functionality, ensuring robust handling of mock lifecycle events. This refactor streamlines mock operations and improves overall code clarity.
…Url handling Enhanced the initBrowserMode method to streamline the initialization process for both single and multiremote browser instances. Added checks to ensure devServerUrl is validated for each instance, improving error handling with SevereServiceError. This refactor clarifies the flow of browser mode setup and ensures consistent behavior across different configurations.
…ementation` details Added comprehensive information about the `mock.withImplementation()` function in browser mode, including its serialization behavior and limitations. Provided usage examples to guide developers on implementing temporary mocks during UI actions. This enhancement improves clarity and usability for users working with browser mode in Electron.
…hImplementation` details Added detailed explanations for the `mock.withImplementation()` function in browser mode, highlighting its serialization process and limitations. Included usage examples to assist developers in implementing temporary mocks during UI actions, enhancing the clarity and usability of the documentation for browser mode in Tauri.
…owser mode Updated the createElectronBrowserModeMock function to improve the mock restoration process, ensuring that the IPC channel remains registered for consistent test behavior. Adjusted the mockRestore method to clear history while preserving the mock's name and channel registration. Additionally, modified the ElectronWorkerService to throw errors when IPC script injection fails after navigation, enhancing error handling. Expanded tests to cover these changes, ensuring robust functionality and clarity in mock management.
Eliminated the unused import of mockStore from mock.ts to clean up the code and improve clarity. This change contributes to better maintainability of the module.
…ron.mock() in browser mode Implemented error handling in the ElectronWorkerService to prevent the use of browser.electron.mock() on the root multiremote browser in browser mode. Updated tests to verify that appropriate error messages are thrown, guiding users to call mock() on specific instances instead. This enhancement improves the robustness of the service and clarifies usage for developers.
…n browser mode Implemented the installation of command overrides in the ElectronWorkerService for browser instances, enhancing the service's functionality. Additionally, ensured that existing mocks are cleared before executing browser commands, improving the reliability of mock behavior during tests. This update contributes to better management of browser interactions and mock lifecycle.
Expose browser.tauri.emitEvent(event, payload?, target?) on the worker-side API. Routes through the in-page registry in browser mode and through Tauri's real event.emit/emitTo via the plugin bridge in native mode, so the same call works in both modes. Adds the new TauriEventTarget union for typed target filtering and documents the feature in api-reference.md plus a new Events section in browser-mode.md covering emitting from tests, targeted emits, asserting on frontend emit() calls, and once() semantics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Browser mode cannot intercept IPC for preloads that use nodeIntegration: true without contextBridge — the synthetic window.electron.ipcRenderer the injection creates won't match a custom API shape. Add a Limitations row and a Troubleshooting entry pointing to contextBridge migration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Arch's nodejs package tracks bleeding-edge releases (currently 26.1.0). Node 26 ships with a tighter undici that rejects something WDIO's webdriver client sends on POST /session — failing every Tauri Arch package test with UND_ERR_INVALID_ARG before any service code runs. Install Node 20 LTS from the official tarball instead. Matches the Node version Ubuntu/Debian pin via setup_20.x and makes Arch builds repeatable across rolling-repo updates. Tracking WDIO + Node 26 compatibility upstream as a follow-up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ction files Streamlined the mock clearing process in both the mock.ts and injection.ts files by replacing array reassignments with length resets. This change enhances performance and maintains the intended functionality during mock resets, ensuring a more efficient clearing mechanism without altering existing behavior.
Introduced an error replacer function in the Electron and Tauri adapters to serialize Error objects, ensuring that error details are preserved during mock data processing. Updated the parseCallData function to reconstruct Errors from serialized data, improving the accuracy of call and result tracking in mock implementations. Enhanced integration tests to validate the round-trip of Error objects in both calls and results.
…send/sendSync Tauri patchBrowserUrl now rethrows after warning, matching the Electron adapter so failed re-injection surfaces immediately instead of producing misleading 'unmocked Tauri command' errors downstream. Electron send and sendSync route to __wdio_mocks__ when a mock is registered, throwing only when no mock exists. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…te missing inner mocks Native-mode emitEvent now branches on target presence in Node before executeScript serializes arguments — this avoids the WebDriver JSON coercion of undefined to null that would route emitEvent(name, payload) through emitTo(null, ...) instead of emit(...). Native-mode mockClear and mockReset in mockFactory now optional-chain through the api/prototype lookup and method invocation, so beforeTest hooks survive the case where a previous test restored the mock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…extracted mock methods The element-command overrides (click/doubleClick/setValue/clearValue) trigger updateAllMocks() after each interaction, dispatching one CDP round-trip per registered mock. They were unintentionally installed in native mode where mocks already sync via the existing CDP path, adding latency proportional to mock count. Restrict installation to browser mode where the renderer-side __wdio_mocks__ state actually needs the post-interaction sync. Bind outerMockClear, outerMockReset, outerMockImplementation, and outerMockImplementationOnce when extracting them in createElectronBrowserModeMock, matching the native createMock factory. Calling these as detached functions previously left this undefined and risked breaking vitest internals that depend on the mock instance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous P1 fix scoped installCommandOverrides to browser mode based on the assumption that the function was new in this PR and added a CDP round-trip per DOM interaction. Verification against main shows the function predates this PR and is required in native mode — the e2e test "should trigger mock updates when DOM interactions occur" depends on it. The .bind(outerMock) fix in mock.ts is preserved as a separate, valid bug fix. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The "without resetting call history or implementation" claim contradicts implemented behaviour: - Electron: when navigation wipes window.__wdio_mocks__, mock() re-registers and replays the implementation but clears call history via mockClear(). Calls accumulated pre-navigation are truncated to zero — opposite of what the doc promised. - Tauri: mock() unconditionally calls mockReset() on the existing mock, clearing both history and implementation on every re-call. The beforeAll-only-set-once example pattern doesn't work; setup must be in beforeEach. After navigation, mock() alone won't re-register the browser-side spy — mockRestore() then mock() is required. Also updates the Navigation example and Troubleshooting entry in the Tauri guide to show the correct mockRestore-then-mock recovery flow. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…mode Element command overrides (click, doubleClick, setValue, clearValue) call updateAllMocks() after every DOM interaction. In native mode this is a redundant CDP round-trip per command — native mocks already sync via executeCdp's per-call update path and via the wrapperMock's update() method. Browser mode genuinely needs the override because there is no CDP-driven auto-sync. Note: the native-mode E2E test "should trigger mock updates when DOM interactions occur" (e2e/test/electron/mocking.spec.ts) was designed to exercise this override path and will need a follow-up — either an explicit await mock.update() in the assertion, or removal if the override is no longer part of the supported native-mode contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous attempt to scope installCommandOverrides() to browser mode broke a large number of E2E suites that rely on element commands (click, doubleClick, setValue, clearValue) auto-syncing native-mode mock state after DOM interactions. Restore the override on the native-mode before() path and reinstate the two unit tests that exercised it. The new browser-mode-only test "should install element command overrides in browser mode" is left in place — it remains valid coverage because the override is now installed via both the native-mode path and the initBrowserMode() path. The Greptile P1 about latency proportional to mock count remains open and should be addressed in a follow-up — most likely by batching updateAllMocks() reads into a single CDP round-trip rather than one call per registered mock. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…llow-up Module-level mockUpdatePending/mockUpdatePromise coalesced concurrent clicks into a single batch, silently dropping later clicks' update data once the in-flight batch had captured its snapshot. State was also shared across all service instances and the promise reference leaked after each batch. Replace with per-browser MockUpdateScheduler (WeakMap-keyed) using a running/queued promise pattern: a click arriving mid-flight enqueues at most one follow-up batch that runs after the current one settles, so post-click data is always captured. Per-browser keying isolates multiremote instances; native-mode mocks (key without \x00) remain shared. Adds integration test suite under test/integration/ with controllable fakes (modeled after tauri-service) covering five scenarios: concurrent batches, coalescing, failure recovery, cross-instance independence, empty-store no-op. Two unit tests added to service.spec.ts for the scheduler queue and recovery paths. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two concurrent browser.electron.mock(channel) calls after navigation could both observe !isBrowserSideLive, both run the registration script, both replay impl state, and both mockClear() — losing any calls made between the two replays. Gate the re-registration through a per-(browser, channel) in-flight promise stored in a WeakMap. Concurrent callers await the same promise so the liveness check, registration script, impl replay, and mockClear all happen exactly once. The slot is cleared in .finally() so a later call retries fresh after a failure. Adds four integration scenarios covering the race, channel independence, the live-browser fast path, and post-failure retry. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three scenarios that exercise the scheduler, registration gate, and patchBrowserUrl together — the wiring between them is otherwise only covered piecewise: - Full navigation cycle: mock, click (scheduler updates), navigate (patchBrowserUrl re-injects), mock again (gate re-registers), click (scheduler updates again). - Four concurrent mock() callers racing to recover after navigation all share a single mockClear and impl replay. - No unhandled rejection emitted when in-flight scheduler work completes after the mock store is dropped. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sendSync returned whatever the mock returned, so mockResolvedValue on a sync channel silently yielded a Promise where callers expected a raw value. Detect the thenable and throw with a message pointing at mockReturnValue / synchronous mockImplementation. window.__wdio_spy__ was reassigned on every injection, so any same-page re-injection (or a manual re-run of the injection script) replaced the factory identity. Match the existing __wdio_mocks__ guard and create the factory only when absent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The launcher was deleting the entire goog:chromeOptions when transforming an Electron capability into browser mode, silently dropping any user-supplied args, extensions, or prefs. Only the `binary` field is Electron-specific; preserve everything else. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The getter built a fresh literal `{ calls, results, invocationCallOrder }`
on every access, so consumers that cached `m.mock` and compared it later
would see different identities even though the inner arrays were the
same. Allocate the snapshot once at mock-creation time and return that
same reference from the getter.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ates per-instance Two related correctness fixes on the browser-mode update path: The MockUpdateScheduler cleared #running in its .finally before the queued chain's #runOnce had a chance to run. A third schedule() arriving in that gap saw #running=null and spawned a parallel batch alongside the queued one. Add an atomic promotion step that hands #queued into #running before the slot is cleared. In multiremote, the element-command override captured the root browser, so the scheduler filtered mocks by the root's per-browser key suffix and matched nothing — per-instance mocks (registered under each instance's suffix) were never updated and tests saw stale call data. Use `this.browser` (the per-instance owning session that fires the override) so the scheduler routes to the right per-instance mock bucket. Tests cover the race window directly and a simulated multiremote override invocation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Document that per-instance devServerUrl is used at startup only, that root url() navigates all instances to the same href, and that mock() on the root multiremote browser is unsupported. Closes the gap surfaced during the multiremote review. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirror Tauri's browser mode for Electron: adds mode='browser' +
devServerUrl to ElectronServiceOptions/GlobalOptions, skips binary
detection and CDP bridge in launcher, injects ipcRenderer IPC stub
into the browser page, and exposes browser.electron.mock(channel) for
test-side IPC channel mocking.
Also extracts WDIO_MOCK_SETUP_SCRIPT from TauriAdapter into a shared
injection.ts constant so both adapters compose from the same browser-
side mock factory, and fully implements ElectronAdapter (previously all
stubs) with all FrameworkAdapter methods.
Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com