feat(electrobun): PR3 feature-complete — mocking, multiremote, deeplink, fixtures#312
Conversation
Greptile SummaryThis PR brings
Confidence Score: 4/5Safe to merge for single-app or multiremote setups; the parallel-workers + multi-app path silently runs the wrong binary. The onWorkerStart resolved-app index bug means that in a parallel multi-worker run where each capability targets a different Electrobun binary, every worker clones and spawns resolvedApps[0] instead of its own resolved app. The vast majority of real setups have all workers pointing to the same binary so this goes unnoticed, but it is a correctness gap for multi-app configurations. Everything else — the CDP bridge, two-layer mock system, multiremote bundle cloning, standalone session, and the three previously flagged issues — is in good shape. packages/electrobun-service/src/launcher.ts — the resolvedApps indexing in onWorkerStart
|
| Filename | Overview |
|---|---|
| packages/electrobun-service/src/launcher.ts | Implements onWorkerStart for native-mode app cloning and port pinning. Has an indexing bug where multi-worker setups with distinct apps per capability always resolve resolvedApps[0] instead of the per-capability entry. |
| packages/electrobun-service/src/innerRecorder.ts | New file: string-builder library for the in-webview spy factory. Circular reference guard in buildReadCallDataScript addresses the previously flagged mockReturnThis issue. |
| packages/electrobun-service/src/mock.ts | New file: two-layer mock implementation. mockRestore correctly uses outerMockReset() (previous issue addressed). Clean lifecycle wiring. |
| packages/electrobun-service/src/nativeMode.ts | New file: per-worker bundle clone and process lifecycle. Temp dir leak on error is now fixed. Path rebase uses String.replace (first-occurrence only), which is safe for normal layouts but fragile. |
| packages/electrobun-service/src/service.ts | Worker service now attaches CdpBridge and installs the full electrobun.* surface. Bridges are closed in both after() and afterSession(); the second call is a safe no-op. |
| packages/electrobun-service/src/session.ts | New file: standalone session helpers. Cleanly drives launcher onPrepare/onWorkerStart manually with proper error cleanup ordering. |
| packages/electrobun-cdp-bridge/src/bridge.ts | New file: multi-target CDP client. Retry/discovery logic and active-target auto-advance after a window close are well-handled. |
| packages/electrobun-cdp-bridge/src/connection.ts | New file: single-target CDP WebSocket connection. Concurrent connect() calls resolve the second caller before the socket is OPEN; safe in the current single-caller usage. |
| packages/electrobun-service/src/electrobunConfig.ts | New file: bundle resolution, CEF verification, and build.json read/write. Comprehensive error messages and best-effort handling for non-macOS platforms. |
| packages/electrobun-service/src/commands/execute.ts | New file: browser.electrobun.execute over CDP Runtime.evaluate. jsonLiteral guards against functions/symbols/circulars with clean error propagation. |
Sequence Diagram
sequenceDiagram
participant TR as WDIO Test Runner
participant L as ElectrobunLaunchService
participant NM as nativeMode
participant W as ElectrobunWorkerService
participant CB as CdpBridge
participant CEF as CEF Webview (CDP)
TR->>L: onPrepare([capA, capB])
L->>L: resolveElectrobunApp + verifyCefRenderer
L-->>TR: resolvedApps[0..n] ready
TR->>L: onWorkerStart(cid, [cap])
L->>NM: cloneAppBundle(bundlePath)
NM-->>L: clonedBundlePath, cloneParentDir
L->>NM: writeRemoteDebuggingPort(clonedBuildJson, port)
L->>NM: spawnElectrobunApp(...)
NM-->>L: ElectrobunAppProcess
L->>L: "cap.debuggerAddress = localhost:port"
TR->>W: before(capabilities, specs, browser)
W->>CB: new CdpBridge + connect()
CB->>CEF: Runtime.enable
W->>W: installApi(browser, bridge, mockStore)
TR->>W: browser.electrobun.mock('api.fetchData')
W->>CB: Runtime.evaluate(buildInstallScript)
CB->>CEF: inject spy + replace window.api.fetchData
W-->>TR: ElectrobunMock handle
TR->>W: mock.update()
W->>CB: Runtime.evaluate(buildReadCallDataScript)
CEF-->>CB: JSON calls/results (circular-safe)
W->>W: "sync into outerMock.mock.*"
TR->>W: mock.mockRestore()
W->>CB: Runtime.evaluate(buildRestoreScript)
CB->>CEF: restore original window.api.fetchData
W->>W: outerMockReset() + store.deleteMock()
TR->>W: after() / afterSession()
W->>CB: bridge.close()
TR->>L: onComplete()
L->>NM: stopElectrobunApp (SIGTERM + rmSync)
Reviews (5): Last reviewed commit: "fix(electrobun): preserve Error.name in ..." | Re-trigger Greptile
- nativeMode: clean up the cloned bundle temp dir if writeRemoteDebuggingPort or the home-dir mkdtemp throws (it isn't tracked for teardown until spawn returns, so a throw leaked the large CEF-bearing clone). + regression test. [P2] - innerRecorder: make the call-data read-back replacer circular/function-safe so a mockReturnThis (or any circular result value) can't throw "Converting circular structure to JSON" out of the page during update(). [P2] - mock: mockRestore now resets the outer spy (impl + history), matching vitest's restore-calls-reset semantics, instead of only clearing history. [P2] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c6bcbe9 to
00daea9
Compare
…one dir on copy failure Greptile (PR #312 review summary, minor error-path gaps): - mock.ts/innerRecorder: include `name` when serialising a rejected-value Error into the page and reconstruct it with `.name`, so mockRejectedValue(new TypeError()) keeps its type in the webview (was dropped → always "Error"). - nativeMode.cloneAppBundle: wrap the copy so the mkdtemp'd parent dir is removed if cp/cpSync throws (e.g. ENOSPC) — it isn't returned/tracked on failure, so it would otherwise leak. + regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- nativeMode: clean up the cloned bundle temp dir if writeRemoteDebuggingPort or the home-dir mkdtemp throws (it isn't tracked for teardown until spawn returns, so a throw leaked the large CEF-bearing clone). + regression test. [P2] - innerRecorder: make the call-data read-back replacer circular/function-safe so a mockReturnThis (or any circular result value) can't throw "Converting circular structure to JSON" out of the page during update(). [P2] - mock: mockRestore now resets the outer spy (impl + history), matching vitest's restore-calls-reset semantics, instead of only clearing history. [P2] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…one dir on copy failure Greptile (PR #312 review summary, minor error-path gaps): - mock.ts/innerRecorder: include `name` when serialising a rejected-value Error into the page and reconstruct it with `.name`, so mockRejectedValue(new TypeError()) keeps its type in the webview (was dropped → always "Error"). - nativeMode.cloneAppBundle: wrap the copy so the mkdtemp'd parent dir is removed if cp/cpSync throws (e.g. ENOSPC) — it isn't returned/tracked on failure, so it would otherwise leak. + regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
029ec9f to
711ba99
Compare
7c4daf5 to
4a10fce
Compare
Replace the triggerDeeplink stub with a real implementation: on macOS, validate the URL (rejects http/https/file) and fire the OS-native protocol handler via @wdio/native-core's deeplink helpers (`open <url>`) so the app's registered open-url handler receives it — the production code path. Non-macOS throws the documented-gap error (Electrobun deeplinks are macOS-only upstream today). - New src/commands/triggerDeeplink.ts (mirrors the dioxus command, macOS-gated). - service.ts installApi wires the real command (drops the stub + now-unused deeplinkUnsupportedOnPlatform import; it's used inside the command). - New test/triggerDeeplink.spec.ts (mocks executeDeeplinkCommand — no real spawn; covers macOS-fires / rejects-http / non-macOS-throws). - Removed the stale service.spec stub assertion (which also fired a real `open`). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author the source for two committable Electrobun fixtures (source only — building is a later CI/PR4 concern): - fixtures/e2e-apps/electrobun: full E2E fixture. CEF renderer enabled on all three OSes (only CEF exposes the CDP endpoint the service attaches to). Two views/windows (mainview counter + secondview) exercise the multi-window switchWindow/listWindows surface. The Bun backend registers an open-url handler that surfaces the deeplink URL into the main view's DOM (window.__wdioDeeplinks + #status) for triggerDeeplink tests. - fixtures/package-tests/electrobun-app: reduced install-smoke fixture — single CEF window, big-glass styling, #app-title + #status only. Both views use the big-glass purple-gradient visual template shared with the Tauri fixture, with stable selectors (#app-title, #counter, #increment-button, #decrement-button, #reset-button, #status). electrobun is pinned to an exact published version (1.18.1) rather than the spike's local file: link. package-test deps are explicit exact versions (no catalog refs) since those run in isolated installs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add fixtures/e2e-apps/electrobun and fixtures/package-tests/electrobun-app to the pnpm-workspace.yaml packages list (mirroring the dioxus entries) and refresh the lockfile with the electrobun@1.18.1 resolution. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each worker now gets its own bundle clone with its own pinned CEF remote-debugging port, instead of mutating the shared bundle's build.json in place. CEF reads the port only from the bundle's build.json (not a launch arg) and is single-instance per cache root, so concurrent same-app instances need both a private bundle copy and a distinct CFFIXED_USER_HOME. - nativeMode: add cloneAppBundle (APFS `cp -Rc` clonefile on darwin, recursive cpSync fallback / non-darwin); spawnElectrobunApp now takes the ResolvedElectrobunApp, clones it, rebases the binary + build.json onto the clone, pins the port into the clone, and spawns the cloned binary. ElectrobunAppProcess tracks cleanupDirs (user home + clone parent); teardown removes both, tolerant of failures. - launcher: drop the in-place writeRemoteDebuggingPort call (clone + port-write now happen inside the spawn path); spawnApp seam passes the resolved app through. - tests: cover the darwin clonefile path, the cpSync fallback, the non-darwin path, the missing-build.json guard, port-pinned-into-clone, cloned-binary spawn, dual-dir teardown, and per-capability distinct ports for multiremote. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(PR3)
Replace the throwing mock stubs in the worker service with a real
mocking surface modelled on Electron's browser-mode mock (in-page
recorder over CDP), satisfying the already-defined ElectrobunMock /
ElectrobunServiceAPI types.
- mock(target): target is a dotted path to a function in the webview
global scope ('api.fetchData' -> window.api.fetchData) — the in-page
analogue of dioxus/tauri's string-target mock, since Electrobun has no
enumerable main-process API.
- Inner recorder (innerRecorder.ts): injected at mock()-time via
bridge.send('Runtime.evaluate', ...) — never navigates. Walks the path,
wraps the target with a vitest-shaped spy under
window.__WDIO_ELECTROBUN_MOCKS__, preserves the original for restore,
and is idempotent (no double-wrap).
- Outer mock (mock.ts): a @wdio/native-spy fn()-backed ElectrobunMock.
update() reads inner call data back over CDP and syncs one-way into
mock.calls/results/invocationCallOrder. mockImplementation/return/
resolve/reject/returnThis (+Once) push behaviour into the inner spy,
reusing execute.ts's JSON-serialisation guard (rejects fn/symbol args).
clear/reset/restore apply to both inner and outer.
- mockStore.ts: per-installed-instance Map (multiremote-safe) backing
clearAllMocks/resetAllMocks/restoreAllMocks(prefix?) and isMockFunction.
- service.ts: installApi() wires the family onto a per-bridge store and
clears stores on teardown. execute.ts factors out evaluateInActiveTarget
+ jsonLiteral for reuse by the mock layer.
mockAll/class-mock are intentionally omitted (Electron-only).
Tests mock the CdpBridge boundary and run the emitted recorder JS in a
Node vm sandbox to prove install -> record -> read-back -> impl/clear/
reset/restore end-to-end. The real in-webview round-trip is only fully
proven against a CEF app (no CEF in unit tests).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- nativeMode: clean up the cloned bundle temp dir if writeRemoteDebuggingPort or the home-dir mkdtemp throws (it isn't tracked for teardown until spawn returns, so a throw leaked the large CEF-bearing clone). + regression test. [P2] - innerRecorder: make the call-data read-back replacer circular/function-safe so a mockReturnThis (or any circular result value) can't throw "Converting circular structure to JSON" out of the page during update(). [P2] - mock: mockRestore now resets the outer spy (impl + history), matching vitest's restore-calls-reset semantics, instead of only clearing history. [P2] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…pare Greptile (PR #310 review summary, "mixed-mode mutation"): the launcher applies a single mode to all caps — browser mode forced browserName:'chrome' on every cap (even native ones) if ANY cap was browser-mode. Fail fast with a clear SevereServiceError on a mixed set instead of silently mis-handling a cap, and require a consistent mode. Fixed in the final (feature-complete) launcher. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…one dir on copy failure Greptile (PR #312 review summary, minor error-path gaps): - mock.ts/innerRecorder: include `name` when serialising a rejected-value Error into the page and reconstruct it with `.name`, so mockRejectedValue(new TypeError()) keeps its type in the webview (was dropped → always "Error"). - nativeMode.cloneAppBundle: wrap the copy so the mkdtemp'd parent dir is removed if cp/cpSync throws (e.g. ENOSPC) — it isn't returned/tracked on failure, so it would otherwise leak. + regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
711ba99 to
34f00c6
Compare
- nativeMode: clean up the cloned bundle temp dir if writeRemoteDebuggingPort or the home-dir mkdtemp throws (it isn't tracked for teardown until spawn returns, so a throw leaked the large CEF-bearing clone). + regression test. [P2] - innerRecorder: make the call-data read-back replacer circular/function-safe so a mockReturnThis (or any circular result value) can't throw "Converting circular structure to JSON" out of the page during update(). [P2] - mock: mockRestore now resets the outer spy (impl + history), matching vitest's restore-calls-reset semantics, instead of only clearing history. [P2] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- nativeMode: clean up the cloned bundle temp dir if writeRemoteDebuggingPort or the home-dir mkdtemp throws (it isn't tracked for teardown until spawn returns, so a throw leaked the large CEF-bearing clone). + regression test. [P2] - innerRecorder: make the call-data read-back replacer circular/function-safe so a mockReturnThis (or any circular result value) can't throw "Converting circular structure to JSON" out of the page during update(). [P2] - mock: mockRestore now resets the outer spy (impl + history), matching vitest's restore-calls-reset semantics, instead of only clearing history. [P2] Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…one dir on copy failure Greptile (PR #312 review summary, minor error-path gaps): - mock.ts/innerRecorder: include `name` when serialising a rejected-value Error into the page and reconstruct it with `.name`, so mockRejectedValue(new TypeError()) keeps its type in the webview (was dropped → always "Error"). - nativeMode.cloneAppBundle: wrap the copy so the mkdtemp'd parent dir is removed if cp/cpSync throws (e.g. ENOSPC) — it isn't returned/tracked on failure, so it would otherwise leak. + regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
PR3 of the stack (Foundation → MVP → Feature-complete → E2E → Ship). Stacked on #311; GitHub retargets to the integration branch as the stack merges.
Brings
@wdio/electrobun-serviceto the full standard surface (all real, not stubbed), validated by unit tests at the CdpBridge boundary. E2E (CI fixture build + specs) is split into the next PR so the beta-toolchain CEF-CI risk doesn't block this feature work or the eventual release.What's in this PR
triggerDeeplink(macOS) — fires the OSopen <url>so the app'sopen-urlhandler receives it (@wdio/native-coredeeplink helpers); non-macOS throws the documented-gap error..appclone (APFScp -Rc,cpSyncfallback) + per-clonebuild.jsonport + distinctCFFIXED_USER_HOME, replacing the MVP's in-place mutation. Removes the single-instance limitation (no upstream change needed — empirically validated in the spike).browser.electrobun.mock(target)+ full lifecycle (mockImplementation/mockReturnValue/mockResolvedValue/mockRejectedValue/…Once/mockReturnThis/mockClear/mockReset/mockRestore) +clear/reset/restoreAllMocks+isMockFunction, over CDP. In-webview vitest-shaped recorder injected viaRuntime.evaluate(never navigates), one-wayupdate()sync — modeled on Electron's browser-mode mock; satisfies theElectrobunMocktypes.fixtures/e2e-apps/electrobun(CEF all-OS, 2 windows,open-url, big-glass counter UI) +fixtures/package-tests/electrobun-app+ workspace wiring.Standard surface — now all real
execute, mocking,switchWindow/listWindows,triggerDeeplink(macOS), browser mode, standalone, multiremote. Documented gaps:emitEvent(Bun bus not CDP-reachable),mockAll/class-mock (Electron-only), Win/Linux deeplink (upstream).Verification
@wdio/electrobun-service+ 18@wdio/electrobun-cdp-bridgeunit tests; Biome clean; no regressions (native-core, dioxus-service, native-types).🤖 Generated with Claude Code