Skip to content

feat(electrobun): PR3 feature-complete — mocking, multiremote, deeplink, fixtures#312

Merged
goosewobbler merged 8 commits into
feat/electrobun-servicefrom
feat/electrobun-feature-complete
May 31, 2026
Merged

feat(electrobun): PR3 feature-complete — mocking, multiremote, deeplink, fixtures#312
goosewobbler merged 8 commits into
feat/electrobun-servicefrom
feat/electrobun-feature-complete

Conversation

@goosewobbler
Copy link
Copy Markdown
Contributor

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-service to 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 OS open <url> so the app's open-url handler receives it (@wdio/native-core deeplink helpers); non-macOS throws the documented-gap error.
  • Multiremote — per-worker .app clone (APFS cp -Rc, cpSync fallback) + per-clone build.json port + distinct CFFIXED_USER_HOME, replacing the MVP's in-place mutation. Removes the single-instance limitation (no upstream change needed — empirically validated in the spike).
  • Mockingbrowser.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 via Runtime.evaluate (never navigates), one-way update() sync — modeled on Electron's browser-mode mock; satisfies the ElectrobunMock types.
  • Fixturesfixtures/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

  • typecheck 10/10; 121 @wdio/electrobun-service + 18 @wdio/electrobun-cdp-bridge unit tests; Biome clean; no regressions (native-core, dioxus-service, native-types).
  • E2E gap (by design): unit tests mock the CdpBridge boundary; the real in-webview round-trip (mock recorder, execute, multiremote clone+spawn) is proven only against a built CEF app — that lands in the E2E PR (CI fixture build + specs).

🤖 Generated with Claude Code

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 31, 2026

Greptile Summary

This PR brings @wdio/electrobun-service to feature-complete status, implementing mocking, multiremote support, triggerDeeplink, and fixture apps on top of the MVP foundation. It adds a full CDP bridge abstraction, a two-layer vitest-shaped mock system (outer spy in the WDIO worker + inner recorder injected into the CEF webview via Runtime.evaluate), per-worker bundle cloning for parallel-safe multiremote, and standalone session helpers.

  • CDP Bridge & Mocking: Adds CdpBridge, Connection, and TargetRegistry in electrobun-cdp-bridge, plus innerRecorder.ts string-builders that inject a vitest-compatible spy into the CEF webview; call data is synced one-way into the outer mock via update().
  • Multiremote / Parallel Safety: Per-worker bundle cloning (cp -Rc APFS fast-path → cpSync fallback) pins each worker's allocated port into build.json and gives each launch its own CFFIXED_USER_HOME.
  • Fixtures & Types: Adds fixtures/e2e-apps/electrobun and fixtures/package-tests/electrobun-app, plus updated @wdio/native-types electrobun surface.

Confidence Score: 4/5

Safe 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

Important Files Changed

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)
Loading

Fix All in Claude Code Fix All in Cursor

Reviews (5): Last reviewed commit: "fix(electrobun): preserve Error.name in ..." | Re-trigger Greptile

Comment thread packages/electrobun-service/src/innerRecorder.ts
Comment thread packages/electrobun-service/src/nativeMode.ts
Comment thread packages/electrobun-service/src/mock.ts
goosewobbler added a commit that referenced this pull request May 31, 2026
- 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>
@goosewobbler goosewobbler force-pushed the feat/electrobun-feature-complete branch from c6bcbe9 to 00daea9 Compare May 31, 2026 10:49
goosewobbler added a commit that referenced this pull request May 31, 2026
…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>
goosewobbler added a commit that referenced this pull request May 31, 2026
- 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>
goosewobbler added a commit that referenced this pull request May 31, 2026
…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>
@goosewobbler goosewobbler force-pushed the feat/electrobun-feature-complete branch from 029ec9f to 711ba99 Compare May 31, 2026 11:38
@goosewobbler goosewobbler force-pushed the feat/electrobun-mvp branch from 7c4daf5 to 4a10fce Compare May 31, 2026 13:18
goosewobbler and others added 8 commits May 31, 2026 14:20
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>
@goosewobbler goosewobbler force-pushed the feat/electrobun-feature-complete branch from 711ba99 to 34f00c6 Compare May 31, 2026 13:22
@goosewobbler goosewobbler changed the base branch from feat/electrobun-mvp to feat/electrobun-service May 31, 2026 13:22
@goosewobbler goosewobbler merged commit 5f14216 into feat/electrobun-service May 31, 2026
3 checks passed
goosewobbler added a commit that referenced this pull request May 31, 2026
- 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>
goosewobbler added a commit that referenced this pull request Jun 1, 2026
- 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>
goosewobbler added a commit that referenced this pull request Jun 1, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant