Skip to content

feat: @wdio/electrobun-service — Electrobun desktop testing support#314

Open
goosewobbler wants to merge 25 commits into
mainfrom
feat/electrobun-service
Open

feat: @wdio/electrobun-service — Electrobun desktop testing support#314
goosewobbler wants to merge 25 commits into
mainfrom
feat/electrobun-service

Conversation

@goosewobbler
Copy link
Copy Markdown
Contributor

@goosewobbler goosewobbler commented May 31, 2026

Aggregate / integration PR for the new @wdio/electrobun-service (ROADMAP Phase 5) — the combined 5-PR stack landing on feat/electrobun-service before it merges to main.

Electrobun is a CDP-attach service (like Electron): the launcher spawns the CEF-rendered app and the worker drives it over the Chrome DevTools Protocol via Chromedriver debuggerAddress, with @wdio/electrobun-cdp-bridge as a multi-target side-channel. The Phase 0 spike validated the approach on macOS (agent-os/specs/20260528-electrobun-service/RESEARCH_FINDINGS.md).

⚠️ Ships pre-1.0 (0.1.0), macOS-only

Driving the E2Es on CI (PR4) surfaced a hard upstream CEF constraint: BrowserWindow forces every window onto a persist:default partition, which CEF's chrome-runtime can't create (root_cache_path is hard-fixed, no override), so CEF falls back to a global browser context. macOS recovers (still serves /json → Chromedriver attaches); Linux/Windows do not (the fallback serves no /json; Linux additionally has remote_debugging_port commented out). This lives in electrobun's prebuilt native lib — not fixable from the service.

So the service ships 0.1.0 (pre-1.0), macOS-only. 0.x because upstream blocks a large part of the convergent surface; 1.0 is reserved for full parity once the gaps fill (minor bumps as each lands).

Phased stack

Phase PR Status
1 — Foundation (skeletons + native-types wiring) #310 ✅ merged
2 — MVP (multi-target CDP bridge, native launcher, execute) #311 ✅ merged
3 — Feature-complete (mocking, deeplink, browser mode, fixtures) #312 ✅ merged
4 — E2E (CEF fixture CI build + specs + wdio conf + headless) #316 ✅ merged
5 — Ship (0.1.0, macOS guard, docs, package-test CI, release pipeline) #318 open

What ships in 0.1.0 (macOS)

execute, mocking (mock + clear/reset/restoreAllMocks + isMockFunction), frontend + backend log capture, browser mode, standalone/session mode, headless — all validated by unit tests, package-install smoke, 3-OS unit/build-tooling, and the macOS single-window E2E (standard suite). A runtime SevereServiceError fails fast on Linux/Windows native mode (framed around the CEF renderer; browser mode still works cross-platform).

Known limitations (upstream-blocked — the "Framework gaps")

Area Status Cause
Linux / Windows (any feature) blocked (guarded) CEF serves no /json there (failed-profile fallback; Linux port commented out)
multiremote / parallel workers blocked CEF can't isolate ≥2 instances per root_cache_path
switchWindow/listWindows (multi-window) API complete, unreliable even on macOS 2-window global-context race
triggerDeeplink (macOS) unreliable no open-url routing to the spawned/cloned instance
emitEvent deferred Bun event bus isn't CDP-reachable (RPC-routable but complex)
mockAll / class-mock N/A Electron-additive; no enumerable main-process API

The blocked/unreliable specs are retained with NOT-RUN-IN-CI notes (runnable locally via TEST_TYPE=…).

Follow-ups

Notes for reviewers

  • Review the individual phase PRs for focused diffs; this is the integration view.
  • The recurring electron-forge no-binary / Windows-teardown CI reds are known-flaky and unrelated to this work (re-run clears them).

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 31, 2026

Release Preview — no release

No bump label detected.
Reason: No release labels found (need bump:* or channel:stable)
Note: Add bump:patch, bump:minor, or bump:major to trigger a release.


Updated automatically by ReleaseKit

Comment thread packages/electrobun-service/src/innerRecorder.ts Fixed
Comment thread packages/electrobun-service/src/innerRecorder.ts Fixed
Comment thread packages/electrobun-service/src/innerRecorder.ts Fixed
Comment thread packages/electrobun-service/src/innerRecorder.ts Fixed
Comment thread packages/electrobun-service/src/innerRecorder.ts Fixed
goosewobbler and others added 24 commits June 1, 2026 19:32
…iring

Bootstrap the Electrobun desktop testing service (ROADMAP Phase 5) as a
CDP-attach archetype, cloned from the Electron service pair. This is the
Foundation PR of a 4-PR stack (Foundation → MVP → Feature-complete → Ship)
that merges into the feat/electrobun-service integration branch.

- native-types: add src/electrobun.ts (ElectrobunServiceAPI, options,
  mock, capabilities as a plain interface, browser extension,
  window.__WDIO_ELECTROBUN__) and wire the WebdriverIO Browser /
  MultiRemoteBrowser / Capabilities / ServiceOption augmentation.
- @wdio/electrobun-cdp-bridge: skeleton for the multi-target CDP client —
  constants (DEFAULT_PORT=9222 + the CEF 9222–9232 auto-select range),
  types (Debugger/Version + PageTarget/TargetClass/TargetRegistryEntry),
  barrel, README, export-shape test. Connection manager lands in MVP.
- @wdio/electrobun-service: skeleton mirroring @wdio/dioxus-service —
  launcher extends BaseLauncher (handles browser mode; native-mode spawn
  + CEF port discovery land in MVP), worker service stub, errors
  (cefRendererRequired / deeplinkUnsupportedOnPlatform), constants,
  serviceConfig, session-free index, unit tests.
- turbo.json: build-graph entries for both new packages.

Decision baked in: CDP only works under Electrobun's CEF renderer, so the
service requires apps built with defaultRenderer:'cef' on all three OSes;
WebKit-default apps are a documented unsupported gap.

Verification: typecheck + unit tests green for both packages (bridge 3/3
@100% cov, service 10/10), Biome clean, no regressions to native-types.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Capture the Phase 0 spike results in agent-os/specs (the tracked
research-findings convention). A throwaway CEF Electrobun app was built and
driven end-to-end on macOS, confirming the CDP-attach + Chromedriver
debuggerAddress model is viable.

Key results that refine PR2:
- Port is PINNABLE — --remote-debugging-port overrides CEF's 9222-9232 scan.
- CEF is single-instance per --user-data-dir → parallel workers need distinct
  user-data-dirs + pinned ports (biggest MVP item).
- Each window/view = one CDP page target (no shell); mock seam is
  Page.addScriptToEvaluateOnNewDocument over window.__electrobun.*.
- Chromedriver matches on major 147; deeplink works on macOS via open-url.
- Still unverified: Linux CDP (remote_debugging_port commented out) + Windows.
- Pin a known-good Electrobun release; 1.18.4-beta.3 ships two build defects.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- launcher: anchor PortManager ports clear of CEF's [9222, 9232] auto-scan
  range (baseNativePort was 9223, inside the range). Electrobun is CDP-attach
  with no native driver, so baseNativePort is nominal — added DEFAULT_DEBUG_PORT_BASE
  (9333) as the allocation anchor. [P1]
- launcher: drop the full JSON.stringify(config/capabilities) debug dumps — the
  testrunner config can carry reporter tokens / cloud credentials that shouldn't
  reach debug logs. [P2]
- cdp-bridge + service constants: correct the misleading "discovers by scanning
  rather than dictating" port comments — the launcher pins the port per worker by
  writing build.json; the 9222-9232 scan is CEF's fallback only. [P1]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot discovered

The remoteDebuggingPort JSDoc still described port auto-discovery (a stale model);
the launcher allocates + pins a port per worker into the bundle's build.json. Reword
so users configuring parallel/multiremote runs aren't misled — each worker gets a
distinct auto-allocated, pinned port; this option only forces a fixed one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source investigation of the Electrobun macOS native wrapper resolving the
spike's blocker 1 (can the debug port + cache dir be set at launch?):

- Port IS launch-overridable: CefMainArgs is built from the process argv, so
  --remote-debugging-port=N reaches CEF's command line and wins over the
  9222-9232 scan. Plan: build without pinning the port; launcher passes a
  distinct allocated port per spawn.
- Cache/user-data dir is NOT launch-overridable: root_cache_path is keyed on
  identifier+channel set via FFI from the Bun launch context, and CEF ignores
  --user-data-dir once root_cache_path is set. Same-app instances share the
  cache root → CEF folds the 2nd launch into the 1st.

Implication: multiremote/parallel is blocked pending an upstream Electrobun
change (cache-root/channel override at launch) — the same shape as Dioxus's
Linux-external gap. MVP ships single-instance; multiremote documented as a
known limitation + upstream tracking issue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the core of the multi-target CDP client (the novel piece vs the
single-target electron-cdp-bridge):

- devTool.ts — /json + /json/version discovery; the consumer keeps ALL page
  targets (CEF exposes one per webview), not just the first.
- connection.ts — one CDP WebSocket per target (devtools-protocol-typed send,
  promise map, EventEmitter). Exposes NO navigate helper — attaching is
  observation-only.
- targetRegistry.ts — pure classify (views:// content vs shell/other) + stable
  labelling (main, window-1, …) keyed on the CEF target id, monotonic counter so
  closed windows don't reclaim labels.
- bridge.ts — CdpBridge: discovers + reconciles targets, holds one connection
  per attached target, routes send()/sendTo()/switchTarget()/listWindows(), and
  enables only Runtime on attach. Invariant: never issues Page.navigate.

Tests: classify/label (incl. stability + no stale reclaim) and a CdpBridge
Page.navigate-never-sent guarantee. 18 tests, typecheck + Biome clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CFFIXED_USER_HOME

The CFFIXED_USER_HOME workaround test corrects the prior launch-time
investigation and resolves multiremote:

- CORRECTION: the --remote-debugging-port launch arg does NOT work. The earlier
  source-inference (CefMainArgs gets process argv) was refuted empirically — the
  shipped main.js launcher never forwards argv to CEF. The port is read only
  from Contents/Resources/build.json chromiumFlags.remote-debugging-port. To
  vary the port per worker, clone the .app (APFS cp -c) and write the port into
  the clone's build.json.
- CFFIXED_USER_HOME per instance redirects CEF's cache root (via
  CFCopyHomeDirectoryURL), defeating single-instance folding. Verified: 2
  concurrent same-app instances both served CDP and were independently
  driveable (Runtime.evaluate 1+1 -> 2 on both ports, 2 process trees, 2 caches).

Result: multiremote/parallel IS achievable on macOS with NO upstream change —
per worker = clone .app + distinct build.json port + distinct CFFIXED_USER_HOME.
MVP (PR2) ships single-instance (N=1); multiremote (PR3) is the same path N>1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lpers

Pure/IO helpers to locate and verify a built CEF Electrobun app and to
read/write the CEF remote-debugging port the launcher pins into the bundle:

- resolveElectrobunApp: require an explicit appBinaryPath (no auto-detection),
  resolve binary/bundle/resources/build.json/identifier. macOS accepts the
  .app dir or the inner binary; Windows/Linux are best-effort (sibling
  build.json) pending layout verification.
- verifyCefRenderer: macOS passes on a bundled CEF framework or a cef
  build.json marker, else throws cefRendererRequired; non-macOS is best-effort
  and never false-negatives.
- readBuildJson / getRemoteDebuggingPort / writeRemoteDebuggingPort: parse and
  pin chromiumFlags["remote-debugging-port"] (string), preserving other keys.

Unit-tested with temp .app fixtures covering resolution, the CEF pass/fail
matrix, and the port read/write round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire the launcher's native-mode path (browser mode unchanged):

- onPrepare: resolve + CEF-verify each app bundle, force browserName='chrome',
  store resolved app info for onWorkerStart. Propagates the missing-path and
  cefRendererRequired SevereServiceErrors.
- onWorkerStart: allocate a CDP port via BaseLauncher.portManager, pin it into
  the bundle's build.json (port is fixed per bundle, not a launch arg — TODO PR3
  clones the bundle per worker instead of mutating in place), spawn the app via
  the new nativeMode module, and set goog:chromeOptions.debuggerAddress.
- onComplete: stop spawned apps (kill + remove temp CFFIXED_USER_HOME) then the
  existing stopAllDrivers + closeLogWriter.

New nativeMode.ts owns the spawn: per-run CFFIXED_USER_HOME temp dir for an
isolated CEF cache root, optional backend stdout/stderr capture via
@wdio/native-core createLogCapture, and SIGTERM→SIGKILL teardown. Adds `env` to
the Electrobun option types so callers can pass extra spawn env.

Unit tests mock the local config + nativeMode seams (no real process/bundle) and
cover the browser/native onPrepare matrix incl. every SevereServiceError throw,
port pinning + debuggerAddress wiring, and teardown. The live spawn/attach path
is an E2E-validation gap (no built CEF bundle in unit tests) — noted in code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion

Worker service before() attaches a CdpBridge to the CEF debugger endpoint read
from goog:chromeOptions.debuggerAddress (set by the launcher) and installs the
browser.electrobun.* surface:

- execute: real — runs the user function (or raw expression) in the active CEF
  content target via the bridge's Runtime.evaluate (returnByValue + awaitPromise),
  args inlined as JSON literals, first callback arg is window.__WDIO_ELECTROBUN__.
- switchWindow / listWindows: real — delegate to bridge.switchTarget / listWindows.
- mock / clearAllMocks / resetAllMocks / restoreAllMocks / isMockFunction /
  triggerDeeplink: stubs that throw a clear not-implemented error (triggerDeeplink
  uses deeplinkUnsupportedOnPlatform off macOS).
- Browser mode (devServerUrl) skips CDP attach. Multiremote attaches per instance.
- after()/afterSession() close bridges defensively (benign-error tolerant).

New session.ts mirrors the dioxus session API (createElectrobunCapabilities /
init / cleanup) over the CDP-attach flow: init() drives launcher onPrepare +
onWorkerStart, opens the Chromedriver session, runs service.before(); cleanup()
runs worker teardown, deletes the session, and calls onComplete. Wired into the
index re-exports as startWdioSession / cleanupWdioSession.

Tests mock the CdpBridge / launcher / service / remote() seams. The live CDP
attach + Runtime.evaluate round-trip is an E2E-validation gap (no built CEF
bundle in unit tests) — noted in code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Searched blackboardsh/electrobun — no existing issue covers a runtime
remote-debugging-port + cache/user-data-dir override for running concurrent
instances (the clean unblock for WDIO multiremote). Adjacent issues: #445
(remote-debug opt-in), #380 (custom CEF cache paths), #227 (single-instance
lock — inverse need), #228 (batteries-included E2E). Drafted a focused feature
request; on hold (maintainer chose not to file yet) — kept on record to revisit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- electrobun-cdp-bridge/devTool: fix the `.catch().then()` chain in
  #executeRequest — the `.then()` ran even after the port-wait rejected (catch
  resolves), firing an HTTP request at a port that never opened. Reordered to
  `.then(req).catch(reject TIMEOUT)`. [P2]
- electrobun-service/execute: reject function/symbol args explicitly. JSON.stringify
  returns undefined (not a throw) for those, so they were silently dropped into
  the inlined script as `undefined`. [P2]
- Test: cover function/symbol arg rejection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n refresh

Greptile (PR #311): refresh() removed dead connections but left #activeLabel
pointing at a closed window, so every later send()/on() threw an opaque
NOT_CONNECTED. Now auto-advance to the first surviving target (or undefined if
none) when the active label is pruned; callers can still switchTarget() explicitly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lose cleanup

Greptile (PR #311 review summary, non-functional style points):
- send(): the per-request timeout timer was never cleared once the response
  arrived, leaving a dangling timer until it fired. Track the timer on the promise
  handler and clearTimeout it in the response handler + bulk reject.
- #close(error): it called #rejectAllPromises(error) AND ws.close() then triggered
  the 'close' handler which rejected again (redundant). Route rejection through the
  single 'close' handler via #closeReason (rejecting directly only when there's no
  open socket / no 'close' event coming).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
CodeQL flagged 5 `js/bad-code-sanitization` alerts on the `${key}` interpolations
in innerRecorder.ts — the mock `target` is `JSON.stringify`'d into scripts the
worker evaluates over CDP. A mock target is only ever a dotted path of JS
identifiers (all the RESOLVE_PATH `.split('.')` walk can address), so restrict it
to that charset in pathLiteral(): the inlined value can then never contain a
quote, backslash, newline, or line/paragraph separator, making JSON.stringify a
provably-safe escaping into the JS-string context (and giving CodeQL a recognised
sanitiser guard). Invalid targets now reject with a clear error before any script
reaches the page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odeQL)

The VALID_TARGET guard hardened the input but CodeQL's js/bad-code-sanitization
still flagged the `${key}` interpolations: a regex `.test()` guard isn't a
sanitiser it recognises, and JSON.stringify leaves U+2028/U+2029 (JS source-string
terminators) raw and doesn't neutralise `</script>`. Finish the escaping on the
JSON.stringify output — the escaping CodeQL recognises for a JS-source context.
No behavioural change (the guard already rejects targets that could contain these).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@goosewobbler goosewobbler force-pushed the feat/electrobun-service branch from b97de7f to f615045 Compare June 1, 2026 18:33
* test(electrobun): add E2E suite for @wdio/electrobun-service

Mirror the tauri/dioxus E2E setup against the CDP-attach electrobun service:

- wdio.electrobun.conf.ts: services ['electrobun'], browserName 'chrome' +
  wdio:electrobunServiceOptions, autoXvfb (Electron headless path, not the Wry
  xvfb-run wrap). Resolves the built CEF .app under fixtures/.../build via glob,
  overridable with ELECTROBUN_APP_PATH. TEST_TYPE selects standard/window/deeplink.
- test/electrobun/: api, application (counter UI), execute, execute-data-types,
  window (switchWindow/listWindows across mainview + secondview, labels
  main/window-1), logging (Bun backend capture), mock (window.<target> spies),
  deeplink (wdio-electrobun scheme, macOS-only). it('should …') throughout.
- e2e/package.json: link @wdio/electrobun-service + test:e2e:electrobun[:window|
  :deeplink] scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): wire E2E build + test jobs (no Rust crates)

Mirror the dioxus CI gating for the CDP-attach electrobun service (no -crates
step — Electrobun has no Rust):

- _ci-build-electrobun-e2e-app.reusable.yml: build the CEF fixture bundle via
  Bun (setup-bun) + `electrobun build`, upload build/ as an artifact. The beta
  Bun/CEF toolchain (~150MB CEF download) is the biggest unknown here.
- _ci-e2e-electrobun-all-providers.reusable.yml: single-provider CDP-attach run
  (no provider matrix); test-type standard/window/deeplink. autoXvfb manages
  Xvfb on Linux (Electron headless path).
- _ci-detect-changes.reusable.yml: add run_electrobun output + electrobun_service
  / e2e_electrobun / fixtures_electrobun / infra_electrobun filters.
- ci.yml: electrobun-gated build + e2e jobs (if run_electrobun && !run_lint_only),
  added to the ci-status gate.

Risk handling: macOS-ARM is the verified/required platform. Linux is a SEPARATE
build + e2e job marked continue-on-error (allow-failure) with a comment, since
the Linux CDP path (CEF serving /json) is unverified — and trivially disabled by
commenting out build-electrobun-e2e-app-linux + e2e-electrobun-linux. Fixture
pins electrobun@1.18.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): fix invalid continue-on-error on reusable-workflow jobs

`continue-on-error` is not permitted on a job that calls a reusable workflow via
`uses:` — GitHub rejected the whole CI run as an invalid workflow file (0 jobs
scheduled), so the electrobun build/e2e jobs never ran. Remove it from the two
Linux jobs and keep Linux non-blocking the valid way: exclude
build-electrobun-e2e-app-linux + e2e-electrobun-linux from `ci-status.needs`.
They still run for signal under their own gate; macOS-ARM stays required. Add
them back once the Linux CDP path is confirmed.

Also swap `ls -R` for `find` in the bundle-verify step (clears shellcheck SC2012).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): pin Bun + pick newest bundle by mtime (Greptile PR #316)

- Pin `bun-version: 1.3.14` (current latest, builds green) so a silent Bun bump
  can't break the fragile beta CEF toolchain; bump deliberately.
- wdio.electrobun.conf.ts: when multiple .app bundles exist (dev/canary/stable),
  pick the most-recently-built (mtime desc) instead of the lexicographically-first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): share CEF bundle as a tarball, not the zip/dist-target archive

The macOS e2e failed at "Verify Electrobun Bundle": the bundle never reached
fixtures/e2e-apps/electrobun/build. Root cause — the shared download-archive
action's extract heuristic only restores dist/dist-js/target dirs, never build/,
so the electrobun bundle (build/dev-macos-arm64/*.app) was extracted to a temp
dir and dropped. (zip -r would also resolve/duplicate the .app's CEF framework
symlinks.)

Share the bundle as a symlink-preserving tar.gz via actions/upload-artifact +
download-artifact instead, and untar into fixtures/e2e-apps/electrobun. The CEF
build itself is already green on macOS + Linux; this is purely the artifact
round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): don't let `find | head` SIGPIPE fail the bundle-verify step

The bundle now extracts correctly, but every e2e job still died at "Verify
Electrobun Bundle" — its last line `find …/build | head -50` closes the pipe
early, so under `set -o pipefail -e` the upstream `find` (thousands of CEF
entries) dies with SIGPIPE (141) and fails the step before any test runs.
Tolerate it (`-maxdepth 3 … | head -50 || true`). Unblocks the actual e2e run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): resolve only the top-level .app, not nested helper bundles

The real cause of the macOS e2e failure: `globSync('build/**/*.app')` also matched
the helper bundles nested inside the main app (Contents/Frameworks/bun Helper
(GPU).app, …). The mtime sort then resolved appBinaryPath to a helper, which has no
CEF framework, so verifyCefRenderer threw cefRendererRequired — on the wrong bundle.
Filter out any `.app` nested inside another `.app` so only the top-level app wins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): spawn the inner launcher binary, not the .app directory

macOS e2e: onPrepare passed but every app spawn failed with EACCES (PID
undefined) → CEF never started → Chromedriver "chrome not reachable". Cause:
resolveMacosBinary guessed the exe is named after the bundle (`Foo.app` → `Foo`),
but Electrobun names its launch exe `launcher`; the guess missed and it fell back
to the `.app` directory, which isn't executable. Resolve the real binary via
Info.plist CFBundleExecutable, then `launcher`, then the bundle-name guess.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): pin chromedriver to CEF's Chromium major (147) for e2e

The app now launches and CEF starts, but Chromedriver refused to attach:
"session not created: This version of ChromeDriver only supports Chrome version
148". WDIO auto-fetched the latest driver (148); Electrobun 1.18.1 bundles CEF on
Chromium 147 (147.0.7727.118). Pin browserVersion: '147' so WDIO uses a major-147
driver — the spike confirmed a 147 driver attaches and drives the app
(RESEARCH_FINDINGS §2). Follow-up: have the service auto-detect CEF's Chromium
version (as electron-service matches Electron's) instead of pinning in the conf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun-cdp-bridge): default undefined options instead of overwriting

Object.assign({...defaults}, options) copies an explicit `undefined` over the
default. An unset service option (cdpConnectionRetryCount / cdpConnectionRetryInterval
/ cdpConnectionTimeout) therefore left timeout undefined (waitPort never waited →
immediate-fail) and connectionRetryCount undefined (`retries >= undefined` never
caps → effectively unbounded retries, ~10k attempts ms apart). Use per-field `??`
so undefined falls back to the default (10s timeout lets waitPort actually wait for
CEF to open the port; 3 bounded retries).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): create Library/Application Support in CFFIXED_USER_HOME

CEF builds its profile under $CFFIXED_USER_HOME/Library/Application Support/…, but
the per-worker home is a fresh mkdtemp dir lacking that structure; CEF won't create
the missing parents and fails ("Cannot create profile at path …"), so it never opens
the debugger port and target discovery times out. Pre-create the parent dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): drop dead artifact_size/cache_key outputs + cache_key plumbing

When the build reusable switched from the shared zip upload-archive action to a
tarball + actions/upload-artifact, its artifact_size/cache_key outputs kept
pointing at steps.upload-archive.outputs.* — which no longer exist, so they were
empty stubs. Nothing consumes them now that the e2e job downloads the bundle by
artifact name, so remove: the two stub outputs, the leftover `id: upload-archive`
on the tar step, the e2e reusable's electrobun_cache_key input, and the two ci.yml
passthroughs. Keeps build_id/build_date (real, matches the sibling reusables).
Cleaner template before this pattern is copied to future framework workflows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): isolate CEF profile via --user-data-dir, not CFFIXED_USER_HOME

CEF couldn't create its profile under the redirected $HOME
($CFFIXED_USER_HOME/Library/Application Support/<id>/dev/CEF/partitions/default —
"Cannot create profile at path"), so it never opened the debugger port. Pin a
per-worker --user-data-dir into the clone's build.json chromiumFlags instead (CEF
reads flags there, not argv): a flat temp dir CEF can create, which also stops a
second launch folding into the first. Drops the CFFIXED_USER_HOME env + the
Library/Application Support pre-create.

Also pin e2e maxInstances=1 to rule out parallel-CEF contention while
single-instance CEF-on-CI is stabilised; raise once parallel runs are verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): wrap string execute scripts (support return/statements)

String-form execute evaluated the script as-is, so a statement-style script
(`return 42`, `const x = …; return x`, `return document.title`) failed with
"Illegal return statement". Wrap strings like @wdio/electron-service: leading
statement keyword or a top-level `;` → function body; otherwise treat as an
expression and return it. Bare expressions and IIFEs (nested `;` only) still
return their value. Reuses @wdio/native-utils' hasSemicolonOutsideQuotes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): tear down each worker's app in onWorkerEnd (no accumulation)

Apps were spawned per worker in onWorkerStart but only stopped in onComplete, so
they accumulated and ran concurrently for the whole run — multiple live CEF
instances contend on profile creation/resources, so only the first worker's app
came up and the rest timed out at session creation (even at maxInstances=1, since
specs are serial but apps stayed alive). Track spawned apps by worker cid and stop
them in onWorkerEnd per-spec; onComplete now sweeps any stragglers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): clean up user-data-dir when port pinning throws (Greptile #316)

userDataDir is created (mkdtemp) before writeRemoteDebuggingPort pins it into the
clone's build.json; if that pin throws (build.json missing/unwritable) the catch
only removed the clone, leaking the profile temp dir. Remove userDataDir too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): focus the WebDriver session on the content window

Chromedriver attaches to whatever CEF page lists first — often a blank shell
(about:blank) separate from the views:// content — so $/click/getText hit a blank
document (browsingContext.locateNodes returned []), while execute/mock worked via
the CdpBridge's content target. After attach, switch the WebDriver session to the
first non-blank window so element commands target the app by default. Best-effort
(logged, not fatal). Fixes application.spec's "element wasn't found" failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): fix invalid upload-artifact@v7 → @v4 (Greptile #316)

The "Upload Test Logs" step used actions/upload-artifact@v7 (no such major; latest
is v4), inconsistent with download-artifact@v4 in the same file and the build
workflow's upload-artifact@v4. Under continue-on-error it would fail silently,
dropping the e2e logs — exactly when they're most needed for the unverified CEF
path. Pin to @v4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): focus the bridge's main window + poll for fixture render

Two robustness fixes for the flaky e2e:
- The WebDriver window focus now targets the window matching the bridge's active
  ('main') target URL (not just the first non-blank one, which could be the
  secondview), so $/click and execute/mock agree on the same content window.
- api.spec's DOM-read polls for #app-title via waitUntil instead of a single
  read — the webview can still be painting when the bridge attaches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): sync the WebDriver window on switchWindow too (Greptile #316)

switchWindow only moved the CdpBridge's active target, so after
browser.electrobun.switchWindow('window-1') the Chromedriver session stayed on the
previous window — $('#second-marker')/$('#second-title') would query the wrong DOM.
Extract the window-alignment into syncWebDriverWindow (matches the bridge's active
target URL, falls back to first non-blank) and call it both after attach and after
every switchWindow, so $/click and execute/mock stay on the same window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun-cdp-bridge): deterministic main window (sort targets by URL)

The 'main' label went to whatever content target CEF's /json listed first, which
isn't stable — so 'main' (and thus execute, switchWindow, and the WebDriver focus)
could flip between mainview and secondview across runs, intermittently breaking
specs that drive the main window. Sort content targets by URL before labelling so
the primary window (views://mainview/…, sorts before secondview) is consistently
'main'. Fixes the application.spec regression from aligning focus to 'main'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): add exploratory Windows e2e build+test jobs (allow-failure)

Add Windows build + e2e jobs mirroring the Linux allow-failure pattern (excluded
from ci-status.needs, so non-blocking) to see whether the CEF toolchain builds and
attaches on Windows — the most promising next platform (Chromium/WebView2 are
CDP-capable). The conf currently globs *.app (macOS), so the Windows e2e is
expected to surface what's needed (build output layout, app-path resolution).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): --force-local so tar works on Windows (drive-letter paths)

The Windows build succeeded but the archive step failed: $RUNNER_TEMP is a drive
path (D:\a\_temp) and GNU tar reads `D:` as a remote host ("Cannot connect to D:").
Pass --force-local on Windows (only — macOS bsdtar lacks it) for both the archive
(build) and extract (e2e) tar calls, so the Windows bundle is produced/round-tripped.
electrobun build itself already works on Windows (downloads win-x64 core + bundles).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): address Greptile PR4 review (conf stat + CI cleanup match)

- wdio.electrobun.conf.ts: stat each globbed bundle once instead of inside the
  sort comparator (which re-stat'd O(n log n) times), guard against a path that
  races away between glob and stat, and drop the redundant statSync that ran
  right after the existsSync check.
- CI cleanup: match the per-worker clone temp-dir prefix (wdio-electrobun-bundle-*)
  in process argv rather than the .app display name. The macOS bundle keeps spaces
  ("WDIO Electrobun E2E-dev.app") and the Windows exe name drops them, so a
  name-based match misses the CEF helper subprocesses; the temp-dir prefix is in
  every spawned process's argv (launcher, bun child, CEF helpers) on both OSes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(electrobun): probe chrome-runtime profile flags for gated window/deeplink

Bounded CEF-on-CI experiment for the gated macOS window+deeplink legs. The CEF
chrome-runtime logs "Cannot create profile" for the persist:default partition
(BrowserWindow forces persist:default and doesn't expose `partition`; the
service's per-worker --user-data-dir lives outside electrobun's NSSearchPath
root_cache_path), so both webviews fall back to the shared global context and
the second window's renderer browser-info response times out. Add no-first-run
+ no-default-browser-check to see whether Chromium then initialises its profile
cleanly under automation. If this doesn't green the window+deeplink legs they
get gated as a documented upstream CEF gap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Revert "test(electrobun): probe chrome-runtime profile flags"

The no-first-run / no-default-browser-check probe was inert: the window leg's
CEF log is identical (same "Cannot create profile" for partitions/default, same
browser-info timeout), and the flags regressed the previously-green standard
leg. The persist:default profile failure is an upstream CEF chrome-runtime
limitation (BrowserWindow forces a custom non-global partition the chrome
runtime can't create → global-context multi-browser race), not something a
chromiumFlag restructures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): gate macOS window+deeplink as allow-failure (upstream CEF gap)

Split e2e-electrobun-macos-arm: the `standard` suite (api/application/execute/
logging/mock) stays the required gate; window + deeplink move to a new
e2e-electrobun-macos-arm-advanced job, excluded from ci-status.needs like the
Linux/Windows legs. They still run for signal.

Both hit an upstream CEF chrome-runtime limitation: BrowserWindow forces a
persist:default partition the chrome runtime can't create as a non-global profile
("Cannot create profile at …/CEF/partitions/default"), so both webviews fall back
to the shared global context and the second window's renderer browser-info
response times out — a multi-browser-on-global-context race electrobun's own
native code documents. Not fixable from the fixture/service (BrowserWindow
exposes no partition; a chromiumFlags probe was inert). Re-fold into the required
job once electrobun makes the failed-profile fallback ephemeral-per-webview or
exposes a per-window partition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): open the fixture's second window only for the window suite

The fixture opened a second CEF BrowserWindow unconditionally, forcing both
windows onto the persist:default partition the CEF chrome-runtime can't create as
a non-global profile. Both fall back to the shared global context and hit
electrobun's documented multi-browser race ("Timeout of new browser info
response") — which can break EITHER window, so api/application intermittently
failed on a non-rendering mainview, making the required `standard` leg flaky (it
passed only once, by luck).

Gate the second window on WDIO_ELECTROBUN_SECOND_WINDOW (set by the window-suite
conf, forwarded launcher→bun via the service `env` option). Single-window suites
(standard, deeplink) no longer create the race and render mainview reliably; the
window suite still opens both and remains gated allow-failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): skip multi-window + deeplink e2e (upstream CEF gap), keep standard

The window (multi-window) and deeplink suites can't run reliably on CI — an
upstream CEF per-instance profile-isolation limitation (BrowserWindow forces a
persist:default partition the chrome-runtime can't create as a non-global profile;
no single-instance/open-url routing). Rather than run them allow-failure (always
red, no signal), they are skipped: the macOS/Linux/Windows e2e matrices now run
only `standard` (single-window), and macOS `standard` stays the required gate.

The window/deeplink specs are kept (with NOT-RUN-IN-CI notes) for local runs
(TEST_TYPE=window|deeplink) and to re-fold into CI once electrobun ships per-window
partitions / per-instance root_cache_path / open-url routing. multiremote stays
maxInstances=1 (blocked upstream). See the agent-os plan "Framework gaps".

(Branch also drops the aligned --user-data-dir experiment — it cleared the profile
error but reintroduced CEF instance-folding → no CDP targets — restoring the
per-worker /tmp user-data-dir as the single-instance config.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): resolve the Linux/Windows bundle layout (unblock their e2e)

Linux/Windows e2e never launched — the config failed to load with "No Electrobun
.app bundle found": the conf globbed `**/*.app` (macOS-only) and the service's
non-macOS resolveElectrobunApp assumed build.json was a sibling of the binary.
Electrobun actually emits `build/<env>/<App>/bin/launcher[.exe]` with
`<App>/Resources/build.json` (verified from the CI build artifacts).

- conf resolveElectrobunAppPath: OS-aware — macOS globs `*.app`; Linux/Windows glob
  `**/bin/launcher[.exe]` (helper exes are `bun Helper (…)`, never `launcher`).
- service resolveElectrobunApp: when the binary is in a `bin/` dir, map the bundle
  root to its grandparent and read build.json from `<root>/Resources` (flat-layout
  sibling fallback kept).

Lets the Linux/Windows `standard` legs start so we can see whether their CEF serves
CDP (Windows sets remote_debugging_port live; Linux has the CefSettings field
commented out but may still get the flag via chromiumFlags).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): install libwebkit2gtk on Linux + always open 2 windows (CDP targets)

Two fixes so the standard e2e launches + exposes CDP across OSes:

- Linux: electrobun's libNativeWrapper.so links libwebkit2gtk-4.1 (it supports both
  the WebKit and CEF renderers), so it failed to dlopen on the CI runner
  ("libwebkit2gtk-4.1.so.0: cannot open shared object file") and the app never
  started — Chromedriver then timed out creating the session. Add libwebkit2gtk-4.1-0
  to the Linux runtime deps.

- macOS/Windows: revert the single-window change. Empirically a single CEF window
  doesn't reliably expose a /json page target (the chrome-runtime global-context
  fallback) → "No CDP page targets" (~1/6 specs pass); two windows expose the main
  target (4–6/6). The fixture opens both windows for every suite again; the second
  view also backs the CI-skipped window suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): wait for CEF /json before attach + next Linux runtime lib

- Launcher waits for CEF to serve /json with a page target (waitForCdpReady) before
  the worker's Chromedriver attaches via debuggerAddress. Without it, Chromedriver
  raced CEF's (slow, on Windows) port binding → "cannot connect to chrome at
  localhost:N" → 120s session timeout × retries (the 11-min Windows run, 1/6 specs).
  Resolves with a warning on timeout — a failed attach is the real signal.
- Linux: add libayatana-appindicator3-1, the next lib libNativeWrapper.so needs to
  dlopen after libwebkit2gtk (incremental electrobun native dep chain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): 127.0.0.1 CDP attach + skip darwin-path unit tests on Windows + format

- e2e: attach to CEF over 127.0.0.1, not 'localhost'. CEF binds the debugger on IPv4,
  but Node/Chromedriver resolve 'localhost' to IPv6 ::1 first on Windows/Linux CI, so
  both the /json readiness poll (waitForCdpReady) and the Chromedriver attach failed
  there (30s timeouts, "cannot connect to chrome"). The bridge inherits the host via
  parseDebuggerAddress, so it connects on IPv4 too. (macOS already worked.)
- unit: skip nativeMode's darwin-mocked clone/spawn/teardown suites on Windows — they
  assert hardcoded POSIX paths node:path can't match on a Windows runner (logic is
  OS-identical since the platform is mocked, covered on Linux/macOS; real Windows
  behaviour is exercised by e2e). Fixes the Unit [Windows] job. Aliased to describe.skip
  so vitest/valid-describe-callback accepts it.
- lint: reflow an over-long line in electrobunConfig.spec.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): run the CEF app under xvfb-run on Linux (headless CI display)

Linux CI runners are headless; CEF is a GUI process and failed with "Failed to open
X11 display" → no browser → no /json (the Linux e2e leg's real blocker, not the CDP
port). WDIO's autoXvfb covers the worker process but not this launcher-spawned app,
so spawn it under `xvfb-run -a` on Linux (macOS/Windows runners have a real display).
Add the xvfb package to the Linux CI deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): drop the /tmp --user-data-dir so the CEF profile creates (catch-22)

The cross-OS e2e blocker was "Cannot create profile at <root_cache_path>/partitions/
default": BrowserWindow forces a persist:default partition whose on-disk profile lives
under CEF's root_cache_path, but the service pinned a /tmp --user-data-dir — and the
chrome-runtime only creates profiles INSIDE --user-data-dir, so the partition was
orphaned → a racy global-context fallback (recoverable on macOS ≈5/6, fatal on
Linux/Windows). Stop injecting --user-data-dir: CEF then uses its own root_cache_path
as the user-data-dir, so the partition profile lands inside it and creates cleanly.

Trade-off: instances share root_cache_path, so this is single-instance (maxInstances=1);
multiremote stays blocked pending an upstream CEF fix (per the agent-os "Framework gaps").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(electrobun): stagger the 2nd window behind mainview's dom-ready (macOS render race)

macOS standard was flaky (4–6/6): whichever spec's app instance lost CEF's
global-context race failed with "fixture #app-title never rendered" (mainview's DOM
unpainted). Opening both CEF windows concurrently lets a browser spawn a separate
top-level window instead of embedding via SetAsChild. Open the second window only
after mainview's dom-ready, so mainview embeds + paints cleanly first; the second
view still opens (needed so the bridge can enumerate window-1). Targets stay
enumerable because both windows are up by the time the bridge attaches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(electrobun): macOS-only e2e — remove Linux/Windows build + e2e jobs

CEF can't serve /json on Linux/Windows: the chrome-runtime can't create the forced
persist:default partition profile, and unlike macOS its global-context fallback
doesn't recover there (no /json → the bridge never attaches) — an upstream electrobun
limitation. Running those legs only burned slow ~150MB CEF builds and produced red
noise, so remove the Linux/Windows build + e2e jobs entirely (rather than
allow-failure). v1 ships macOS-only (pre-1.0); re-add when electrobun makes the
failed-profile fallback ephemeral-per-webview. See the implementation plan
"Framework gaps".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(skills): add-native-service — pre-1.0 strategy when upstream blocks the surface

Add a "When upstream blocks the standard surface (shipping pre-1.0)" section: ship the
working subset rather than blocking on upstream, version it 0.1.0 (not 1.0.0-next.0)
with minor bumps as upstream lands fixes and 1.0.0 at full convergent-surface parity,
SKIP (don't allow-failure) the upstream-blocked CI legs, fail fast with a runtime
SevereServiceError on unsupported platforms, keep blocked specs (skipped + local-only),
and file upstream issues. Electrobun (macOS-only, 0.1.0) is the worked example. Also
note the 0.1.0 release-notes path in ci-and-release.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(electrobun): drop stale CFFIXED_USER_HOME from launcher class JSDoc

The launcher no longer redirects the CEF cache root (CFFIXED_USER_HOME / per-worker
--user-data-dir were both disproven, then removed): CEF uses its own root_cache_path,
which makes v1 single-instance macOS-only. Update the class JSDoc to match — drop the
CFFIXED_USER_HOME mention + the "parallel-safe" claim, and add the /json-readiness wait
step. See the implementation plan "Framework gaps".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(skills): add-native-service — refine upstream-gap workflow + local-checkout precursor

When upstream blocks the surface: aggregate gaps in the plan, search existing issues
(open + closed) first, and file ONE umbrella issue connecting the upstream's existing
issues to the consumer goal rather than N duplicates. Cover the edge cases the first
pass missed: net-new gaps (no existing issue) are captured inline in the umbrella and
split into their own issue only when independently actionable; pick the filing shape by
whether aggregation helps triage (count is a heuristic) — one gap never gets an umbrella,
two related gaps share one combined issue, three+ related gaps get the full umbrella.
Also make checking out the target framework's source locally an explicit, non-optional
Phase 0 precursor — it's read constantly to confirm the archetype and cite gap source
refs. Update the electrobun worked example with the real issue map (#380/#445/#448 +
closed #278/#122).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(electrobun): retry e2e spec files to absorb the residual macOS CEF render race

The macOS `standard` suite occasionally fails one spec with `#app-title never rendered`.
The 2-window fixture (needed because a single CEF window exposes no `/json` target) trips
CEF's failed-profile -> global-context fallback into spawning a separate top-level window
instead of embedding, leaving the main view unpainted for that app instance's whole life.
mochaOpts.retries can't escape it (same instance); specFileRetries re-spawns a fresh CEF
instance. Upstream race (plan "Framework gaps") — honest mitigation until the
ephemeral-per-webview fallback lands upstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(electrobun): fix stale spawnElectrobunApp JSDoc (no --user-data-dir pinned)

The JSDoc claimed spawnElectrobunApp pins a per-run --user-data-dir into the clone's
build.json, contradicting both the inline comment and the implementation, which
deliberately pins ONLY the port (CEF's own root_cache_path stays the user-data-dir so
the forced persist:default profile creates cleanly — the single-instance trade-off).
Align the JSDoc with the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(electrobun): drop redundant post-loop spawnedAppsByCid.set

onWorkerStart already sets spawnedAppsByCid inside the loop (so a waitForCdpReady
failure still leaves the spawned app tracked for teardown), and workerApps is stored
by reference — the subsequent push()es are already visible through the map. The set
after the loop re-stored the same reference, a no-op. Removing it also avoids adding
a spurious empty entry when a worker has no capabilities.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
@goosewobbler goosewobbler marked this pull request as ready for review June 2, 2026 12:18
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Jun 2, 2026

Greptile Summary

This PR lands @wdio/electrobun-service (v0.1.0, macOS-only) and its companion @wdio/electrobun-cdp-bridge package into the monorepo — a CDP-attach WDIO service for testing Electrobun desktop apps over CEF's Chrome DevTools Protocol. It is the integration view of a 5-phase stack (phases 1–4 merged) that introduces the launcher, worker service, multi-target CDP bridge, mock layer, deeplink support, E2E fixtures, and CI workflows.

  • CDP bridge (@wdio/electrobun-cdp-bridge): multi-target WebSocket client with per-target Connection, TargetRegistry for stable window labels, and retry-backed discovery
  • Service layer (@wdio/electrobun-service): per-worker bundle cloning with APFS clonefile fast-path, build.json port-pinning, Runtime.evaluate-based execute/mock, syncWebDriverWindow alignment, and a standalone session API (init/cleanup)
  • E2E & CI: macOS-only standard suite validated in CI with a CEF fixture app; Linux/Windows blocked by upstream CEF constraints and guarded accordingly

Confidence Score: 4/5

The service is well-structured and the core attach/teardown flow is sound; all findings are non-blocking improvements.

The implementation is thorough: bundle cloning with APFS fast-path, retry-backed CDP discovery, idempotent mock install, and proper per-worker cleanup. The findings are localised style and documentation issues plus a minor window-state edge case in the best-effort sync helper. Nothing here would cause silent data corruption or a broken run on the happy path.

packages/electrobun-service/src/service.ts (syncWebDriverWindow window-state edge case), packages/electrobun-service/src/mock.ts (Error stack asymmetry), packages/electrobun-service/src/electrobunConfig.ts (stale docstring)

Important Files Changed

Filename Overview
packages/electrobun-service/src/launcher.ts Launcher correctly clones the bundle per-worker, pins the CDP port into build.json, and waits for the CEF /json endpoint before setting debuggerAddress. The spawnApp seam, APFS fast-path fallback, and per-cid cleanup are well handled.
packages/electrobun-service/src/nativeMode.ts Process management is sound: clone cleanup on pin failure, SIGTERM→SIGKILL grace window, and log handler teardown. The deliberate omission of --user-data-dir is well-documented inline. waitForCdpReady always resolves (with a warning on timeout).
packages/electrobun-service/src/service.ts Worker service attaches the CDP bridge and installs browser.electrobun.*. syncWebDriverWindow is best-effort but can leave the browser on the last-tried handle when no match is found — could confuse test debugging in multi-window scenarios.
packages/electrobun-cdp-bridge/src/connection.ts WebSocket lifecycle handling is correct: CONNECT_PROMISE_ID at 0, command IDs starting at 1, per-send timeouts, and double-close safety. The async on the message handler is unnecessary since #messageHandler is synchronous, but harmless.
packages/electrobun-cdp-bridge/src/bridge.ts Multi-target bridge is well-structured: per-field ?? defaults fix the previously-undefined-timeout bug, #discover retry loop is bounded, and active-target auto-advance on window close is correctly handled.
packages/electrobun-service/src/mock.ts Mock implementation is solid: idempotent install, one-way sync of call history, and proper clear/reset/restore delegation. Error serialization for mockRejectedValue does not include stack trace unlike the read-back path.
packages/electrobun-service/src/innerRecorder.ts In-page spy factory and script builders are well-implemented with valid-target guard, pathLiteral escaping, and circular-reference protection in the read-back serializer.
packages/electrobun-service/src/electrobunConfig.ts Bundle resolution and port-pinning are well-guarded. The writeRemoteDebuggingPort docstring describes a user-data-dir use case that conflicts with the explicit design decision in nativeMode.ts to never inject one.
packages/electrobun-service/src/session.ts Standalone session init/cleanup is correctly sequenced — launcher tracked before service.before, cleaned up on both service.before failure and remote session failure.
packages/electrobun-cdp-bridge/src/targetRegistry.ts URL-sorted label assignment and stable reconcile logic are correct. labelOrder returns NaN for unexpected label formats but labels are always 'main' or 'window-N' so this is unreachable in practice.

Sequence Diagram

sequenceDiagram
    participant LCI as CI Runner
    participant L as ElectrobunLaunchService (main)
    participant NM as nativeMode (cloneBundle / spawn)
    participant CEF as Electrobun App (CEF)
    participant W as ElectrobunWorkerService (worker)
    participant B as CdpBridge
    participant T as Test

    LCI->>L: onPrepare(caps)
    L->>L: resolveElectrobunApp + verifyCefRenderer
    L->>L: force browserName:'chrome'

    LCI->>L: onWorkerStart(cid, caps)
    L->>NM: spawnElectrobunApp(cloneBundle, pinPort)
    NM->>NM: cp -Rc bundle to tmp clone
    NM->>NM: writeRemoteDebuggingPort into build.json
    NM->>CEF: spawn clonedBinary
    NM-->>L: ElectrobunAppProcess
    L->>CEF: waitForCdpReady (poll /json)
    CEF-->>L: page target ready
    L->>L: set goog:chromeOptions.debuggerAddress

    LCI->>W: service.before(caps, browser)
    W->>B: new CdpBridge(host, port)
    B->>CEF: WebSocket connect
    B->>CEF: Runtime.enable
    W->>W: installApi + syncWebDriverWindow

    T->>W: browser.electrobun.execute(fn)
    W->>B: bridge.send(Runtime.evaluate)
    B->>CEF: CDP evaluate
    CEF-->>W: result

    T->>W: browser.electrobun.mock(target)
    W->>CEF: buildInstallScript via Runtime.evaluate
    W-->>T: ElectrobunMock

    LCI->>L: onWorkerEnd(cid)
    L->>NM: stopElectrobunApp SIGTERM/SIGKILL
    NM->>NM: rmSync cloneParentDir

    LCI->>W: service.after()
    W->>B: bridge.close()
Loading

Fix All in Claude Code Fix All in Cursor

Reviews (1): Last reviewed commit: "feat(electrobun): E2E suite + CI build/t..." | Re-trigger Greptile

Comment thread packages/electrobun-cdp-bridge/src/connection.ts
Comment thread packages/electrobun-service/src/service.ts
Comment thread packages/electrobun-service/src/electrobunConfig.ts
Comment thread packages/electrobun-service/src/mock.ts
goosewobbler added a commit that referenced this pull request Jun 2, 2026
- mock.ts: include `stack` when serialising an Error mock value (mockRejectedValue),
  matching the read-call-data path — no asymmetry for users inspecting thrown errors.
- electrobunConfig.ts: fix writeRemoteDebuggingPort JSDoc — the userDataDir param is
  supported but the launcher intentionally never passes it (the disproven approach),
  contradicting the old "isolates each worker's profile" claim.
- service.ts: syncWebDriverWindow restores the caller's original window handle on the
  no-match path instead of stranding the session on the last-probed handle.
- connection.ts: drop the unnecessary `async` on the ws 'message' handler (the body is
  sync; only the catch is async) — `void this.#errorHandler(error)`.
- service.spec.ts: add getWindowHandle to the browser mock for the restore path.

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.

2 participants