diff --git a/.claude/skills/add-native-service/SKILL.md b/.claude/skills/add-native-service/SKILL.md index 05dc1bce4..6d316eba7 100644 --- a/.claude/skills/add-native-service/SKILL.md +++ b/.claude/skills/add-native-service/SKILL.md @@ -85,17 +85,104 @@ New services build **on `@wdio/native-core`** (Tauri and Dioxus do; Electron pre → **Full inventory, the known divergences to converge (Electron's window model, Dioxus's missing `emitEvent`), and the pattern behind each:** [features.md](features.md). +## When upstream blocks the standard surface (shipping pre-1.0) + +Some frameworks — especially a **beta/pre-1.0 upstream** — can't yet support the full convergent +surface above: a platform may have no working automation path, or a standard feature (multiremote, +multi-window, deeplink) may be blocked by a framework/runtime limitation you **cannot fix from the +service layer**. Don't force-fit, and don't hold the whole package hostage to upstream — ship the +working subset, clearly scoped, and recover the rest as upstream lands fixes. + +**Version: base at `0.1.0`, not `1.0.0`.** The default convention here is a `X.Y.0-next.0` dev +placeholder in `package.json` that releases as stable `X.Y.0` on `latest` (with `-next.N` prereleases +on the `next` dist-tag in between — every service does this: electron `10.0.0-next.N`→`10.0.0`, tauri +`1.0.0-next.N`→`1.0.0`). A full-convergent-surface service bases that at `1.0` (placeholder +`1.0.0-next.0`, release `1.0.0`). When upstream blocks a lot, `1.0` over-promises — base it at **`0.x`**, +the semver signal for "early, partial, scope may change, gaps expected". Keep the same release +*machinery*, just lower the base: +- **Dev placeholder `0.1.0-next.0`** (a prerelease *of* 0.1.0 — NOT `1.0.0-next.0`, which implies a 1.0 target). +- **First stable release `0.1.0`** on `latest`; `0.1.0-next.N` prereleases on `next` during lead-up. (`0.x` = unstable API; `-next.N` = staging channel — orthogonal, you want both.) +- **Minor bumps** (`0.2.0`, `0.3.0`…) as each upstream fix recovers a platform/feature. Breaking changes are allowed within `0.x` (bump minor). +- **Graduate to `1.0.0`** only at full parity with the sibling services — the whole standard surface on all intended platforms. `1.0` is the promise that the convergent surface works. + +**Be honest in CI — skip, don't allow-failure.** For a platform/suite that's **blocked upstream** +(not merely flaky), **remove its jobs entirely** rather than marking them allow-failure. Allow-failure +legs that can *never* pass only burn CI minutes (slow runtime downloads, etc.) and add permanent red +noise that trains everyone to ignore the column. Keep the validated platform/suite as the **required +gate**; leave a comment on the removed jobs naming the upstream blocker and the condition to re-add +them. (Reserve allow-failure for legs that are *unverified-but-plausible*, not *known-blocked*.) + +**Fail fast at runtime.** Add an explicit `SevereServiceError` in `launcher.onPrepare` for an +unsupported platform/mode, with an actionable message ("`` is macOS-only in v1 — `` +is blocked by ``"). A clear early throw beats letting users hit a cryptic +attach/connection timeout. Gate it on a platform parameter (not bare `process.platform`) so the +launcher tests can exercise both branches. + +**Keep the blocked specs, don't delete them.** Leave the blocked-feature e2e specs in the tree with a +NOT-RUN-IN-CI header comment, runnable locally via `TEST_TYPE=…`, and excluded from the CI matrix. +They are the re-enable checklist for when upstream lands. + +**Document + file upstream — aggregate in the plan first, then ONE issue.** As you discover gaps, +collect them into a dedicated **"Upstream fixes needed"** section of the implementation plan — each +with its impact on the surface, the feature/platform it unblocks, and **exact source refs** +(`file:line`). Gaps surface across many debugging sessions; without one home in the plan they get lost, +and that section becomes the turnkey brief for the post-ship filing step. Before filing anything, +**search the upstream repo's issues (open *and* closed)** for every gap: a young/beta upstream usually +already tracks several, and a **closed** issue often explains current behaviour — e.g. a "completed" +fix that was really a band-aid fallback is frequently *why* one platform works while others don't. +Then choose the filing shape by **whether aggregation actually helps triage** — the gap *count* is +only a heuristic, so don't reflexively build (or skip) an umbrella on a number alone: +- **One gap → never an umbrella.** Nothing to aggregate. If it's already tracked upstream, comment + on / +1 the existing issue; if it's net-new, file one focused issue. +- **Two gaps → usually still no umbrella, but combine if related.** If the two share a root cause or + the same consumer goal, file **one issue covering both** — a lightweight combined issue, *not* the + full see-also structure. If they're unrelated, handle each on its own (comment on the existing + issue, or file a focused one). The deciding question is "does framing them together help the + maintainer?", not "are there two of them?". +- **Three or more related gaps → ONE umbrella issue**, framed around the consumer goal ("drive `` + apps with external WebDriver/CDP automation"). For each gap that **already has an issue**, link it + (`see also #N`) — don't duplicate. Each **net-new** gap (no existing issue) is captured *by the + umbrella itself*, since the umbrella is a new issue: describe it inline as its own section with + source refs. Only **split a net-new gap into its own dedicated issue** (then link it from the + umbrella, `see also #N`) when it's large and cleanly separable enough that the maintainer would want + to triage/close it independently — otherwise inline is enough. A single well-researched issue + connecting the maintainer's own scattered issues to a concrete use case triages far better — and + gives you **one canonical URL** to link everywhere — than several parallel issues that duplicate + what's already filed. Drop a one-line cross-link comment on the most directly related existing issues. + +Record every gap as a known limitation in the service README/docs + +`ROADMAP.md`, and link the umbrella issue into the docs + the CI re-add notes. As each underlying fix +lands: re-add the job, drop the runtime-guard branch, lift the docs limitation, and bump the minor +version. + +> **Worked example — `@wdio/electrobun-service`.** Electrobun's CEF chrome-runtime can't create the +> `persist:default` partition profile its `BrowserWindow` forces; macOS recovers via a global-context +> fallback but Linux/Windows serve no `/json`, and multiremote/multi-window/deeplink all trace to the +> same gap — none fixable from the service. So v1 ships **macOS-only, single-window, `0.1.0`**: the +> Linux/Windows build+e2e jobs are removed (not allow-failure), the window/deeplink specs are +> skipped-but-kept, and a macOS-only runtime guard fails fast elsewhere. The plan's "Framework gaps" +> aggregates every gap with source refs; the **search-first** pass found the upstream already tracking +> most of them — `#380` (the proper profile-isolation fix), `#445` (remote-debugging opt-in, but only +> noting macOS — Linux's `remote_debugging_port` is *commented out*), `#448` (a user hitting the same +> Linux profile error), plus `#278`/`#122` **closed** (the global-context band-aid that explains macOS +> recovery, and a prior e2e request). So rather than file four duplicates, the post-ship step is **one +> umbrella issue** ("enable external WebDriver/CDP automation for CEF apps") that links those, adds the +> net-new findings (the Linux commented-out port; the macOS-recovers/others-don't `/json` asymmetry; +> single-instance lock + `open-url` routing for deeplink — which has *no* existing issue), and is the +> one URL linked from the docs. + ## Process ### Phase 0 — Pre-implementation spike Validate platform constraints before any production code. Spikes are throwaway — they live in `/spike/-spike/` (gitignored); the output is the **findings doc**, not the source. -1. Create a minimal app exercising the framework's public API. -2. **Confirm the Step 0 archetype** (does it expose CDP? which driver model? plugin or bridge?). -3. Identify the single most consequential unknown and write code that exercises it. Examples: "can a third-party crate flip Wry's `set_allows_automation`?" (Dioxus); "what's the CDP debugger-port discovery convention for this runtime?" (a CDP framework); "does the framework expose multiple addressable webview targets?". -4. Write `spike/FINDINGS.md`: the question, the answer with citations, the decision tree, and any **platform-by-platform variance** (often the most important section — e.g. the Dioxus Wry API is a no-op on Win/Mac but blocking on Linux). -5. Fold findings into the implementation plan: Risks, Platform Matrix, Phasing. +1. **Check out the target framework's source locally — a hard precursor, not optional.** Clone the upstream repo at a known-good *released* tag (don't `file:`-link it into the build; pin the published release — see Risks). You will read it constantly throughout the whole project: to confirm the Step 0 archetype, to find the runtime's debug-port / automation conventions, and — every time you later hit an upstream gap — to trace it to an exact `file:line` you can cite. A service built without the framework source open beside you is guesswork; note its path in the plan (e.g. `~/Workspace/`) so later sessions reuse it. +2. Create a minimal app exercising the framework's public API. +3. **Confirm the Step 0 archetype** (does it expose CDP? which driver model? plugin or bridge?) — by reading the source from step 1, not by assuming. +4. Identify the single most consequential unknown and write code that exercises it. Examples: "can a third-party crate flip Wry's `set_allows_automation`?" (Dioxus); "what's the CDP debugger-port discovery convention for this runtime?" (a CDP framework); "does the framework expose multiple addressable webview targets?". +5. Write `spike/FINDINGS.md`: the question, the answer with citations (`file:line` into the local checkout), the decision tree, and any **platform-by-platform variance** (often the most important section — e.g. the Dioxus Wry API is a no-op on Win/Mac but blocking on Linux). +6. Fold findings into the implementation plan: Risks, Platform Matrix, Phasing. ### Phase 1 — TypeScript service skeleton (shared, all archetypes) @@ -113,7 +200,7 @@ src/ **Conventions:** -- Initial npm version `1.0.0-next.0`. Build script: `tsx ../../scripts/build-package.ts`. +- Initial `package.json` dev placeholder `1.0.0-next.0` for a service that reaches the full convergent surface on its target platforms (releases as stable `1.0.0`) — or **`0.1.0-next.0`** (releases as `0.1.0`) if upstream blocks a lot of the surface (see [When upstream blocks the standard surface](#when-upstream-blocks-the-standard-surface-shipping-pre-10)). Build script: `tsx ../../scripts/build-package.ts`. - Mirror the closest sibling's `package.json` exactly (exports, scripts, devDeps, peerDeps): CDP → clone `@wdio/electron-service`; Wry → clone `@wdio/tauri-service`. Always depend on `@wdio/native-core`, `@wdio/native-spy`, `@wdio/native-types`, `@wdio/native-utils` as workspace deps. - `vitest.integration.config.ts` MUST set `fileParallelism: false` + 30s timeout + `setupFiles: ['test/integration/setup.ts']`. - `tsconfig.json` extends `../../tsconfig.base.json`, out `./dist`, root `./src`. diff --git a/.claude/skills/add-native-service/ci-and-release.md b/.claude/skills/add-native-service/ci-and-release.md index e6d73ba97..065387c38 100644 --- a/.claude/skills/add-native-service/ci-and-release.md +++ b/.claude/skills/add-native-service/ci-and-release.md @@ -51,6 +51,12 @@ To add a framework: - **Rust setup + GTK libs**: the "Setup Rust" and "Install GTK development libraries" steps run `if: contains(inputs.scope, 'tauri') || contains(inputs.scope, 'dioxus')`. Add `|| contains(inputs.scope, '')` for a new Wry framework. CDP frameworks need neither. 3. Rust crates publish to crates.io via `CARGO_REGISTRY_TOKEN` (from `crates_io_token` secret); npm packages publish with provenance. Both already wired in the `Run ReleaseKit` step — listing the crate in the target set is enough. -### Per-package release notes - -Each shipped service keeps `docs/release-notes/v1.0.0.md`; Wry crates keep `docs/release-notes/` too (e.g. `dioxus-bridge/docs/release-notes/v1.0.0.md`). Add the v1 note before the Ship PR. +### Release notes — generated by ReleaseKit, do NOT hand-author + +ReleaseKit generates the release notes + `CHANGELOG.md` as part of the release (configured under +`notes` in `releasekit.config.json`; LLM-enhanced when an Ollama key is present). The +`docs/release-notes/.md` files in the repo (e.g. `dioxus-service/docs/release-notes/v1.0.0.md`) +are **generated artifacts committed by a release**, not pre-Ship inputs — so do **not** hand-author a +`docs/release-notes/.md` for a new service; it appears when the release runs. Listing the +package in the `_release.reusable.yml` target set is enough. Supported-subset/limitations prose is +hand-authored in the service README + `docs/` instead. diff --git a/.github/workflows/_ci-build-electrobun-e2e-app.reusable.yml b/.github/workflows/_ci-build-electrobun-e2e-app.reusable.yml new file mode 100644 index 000000000..b49e80d09 --- /dev/null +++ b/.github/workflows/_ci-build-electrobun-e2e-app.reusable.yml @@ -0,0 +1,137 @@ +name: Build Electrobun E2E App +# Description: Builds the Electrobun E2E test application (CEF bundle) and creates shareable artifacts. +# +# Electrobun has NO Rust component (unlike Dioxus) — it builds with Bun and bundles +# CEF. The CEF toolchain is a beta/unverified path: `electrobun build` downloads a +# ~150MB CEF framework, so the build is slower and more network-dependent than the +# other e2e-app builds. macOS is the only validated platform; Windows/Linux bundle +# layout is unverified (see packages/electrobun-service RESEARCH_FINDINGS). + +permissions: + contents: read + +on: + workflow_call: + inputs: + os: + description: 'OS of runner' + required: true + type: string + build_id: + description: 'Build ID from the main build job' + type: string + required: false + artifact_size: + description: 'Size of the build artifact in bytes' + type: string + required: false + cache_key: + description: 'Cache key to use for downloading artifacts' + type: string + required: false + outputs: + build_id: + description: 'Unique identifier for this build' + value: ${{ jobs.build-electrobun-e2e-app.outputs.build_id }} + build_date: + description: 'Timestamp when the build completed' + value: ${{ jobs.build-electrobun-e2e-app.outputs.build_date }} + +env: + TURBO_TELEMETRY_DISABLED: 1 + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + +jobs: + build-electrobun-e2e-app: + name: Build + runs-on: ${{ inputs.os }} + outputs: + build_id: ${{ steps.build-info.outputs.build_id }} + build_date: ${{ steps.build-info.outputs.build_date }} + steps: + - name: 👷 Checkout Repository + uses: actions/checkout@v6 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + + - name: 🛠️ Setup Development Environment + uses: ./.github/workflows/actions/setup-workspace + with: + node-version: '24' + + - name: 🥟 Setup Bun + uses: oven-sh/setup-bun@v2 + with: + # Pinned for reproducibility — the beta CEF toolchain is fragile, so a + # silent Bun bump must not break `electrobun build`. Bump deliberately. + bun-version: '1.3.14' + + - name: 📊 Generate Build Information + id: build-info + shell: bash + run: | + echo "build_id=$(date +%s)-${{ github.run_id }}-${{ runner.os }}" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_OUTPUT" + + - name: 📦 Download Package Build Artifacts + uses: ./.github/workflows/actions/download-archive + with: + name: wdio-desktop-mobile + path: wdio-desktop-mobile-build + filename: artifact.zip + cache_key_prefix: wdio-desktop-build + exact_cache_key: ${{ inputs.cache_key || github.run_id && format('{0}-{1}-{2}-{3}{4}', 'Linux', 'wdio-desktop-build', 'wdio-desktop-mobile', github.run_id, github.run_attempt > 1 && format('-rerun{0}', github.run_attempt) || '') || '' }} + + - name: 📊 Show Build Information + if: inputs.build_id != '' && inputs.artifact_size != '' + shell: bash + run: | + echo "::notice::Build artifact: ID=${{ inputs.build_id }}, Size=${{ inputs.artifact_size }} bytes" + + - name: 📦 Install Dependencies + run: pnpm install --frozen-lockfile + shell: bash + + # CEF download + bundle. The beta Bun/CEF toolchain is the biggest unknown in + # this pipeline (~150MB CEF download); a failure here is most likely a + # toolchain/network issue rather than a regression in the fixture itself. + - name: 🏗️ Build Electrobun E2E App + run: pnpm run build + shell: bash + working-directory: fixtures/e2e-apps/electrobun + + - name: 🔎 Locate Built Bundle + id: locate + shell: bash + run: | + # shellcheck disable=SC2012 + echo "Build output tree:" + ls -R fixtures/e2e-apps/electrobun/build || true + if [ ! -d "fixtures/e2e-apps/electrobun/build" ]; then + echo "::error::Electrobun build directory was not produced." + exit 1 + fi + + # NOT the shared zip-based upload-archive action: the macOS .app carries CEF + # framework symlinks that `zip -r` resolves/duplicates, and download-archive's + # extract heuristic only restores dist/target dirs (never build/). A + # symlink-preserving tarball shared via actions/upload-artifact round-trips the + # bundle intact. + - name: 🗜️ Archive bundle (tar preserves .app framework symlinks) + shell: bash + run: | + # On Windows, $RUNNER_TEMP is a drive path (D:\a\_temp) and GNU tar reads the + # `D:` as a remote host ("Cannot connect to D:") — --force-local treats it as + # a local path. macOS bsdtar lacks that flag, so only pass it on Windows. + flags="" + [ "$RUNNER_OS" = "Windows" ] && flags="--force-local" + tar $flags -czpf "${RUNNER_TEMP}/electrobun-e2e-bundle.tgz" -C fixtures/e2e-apps/electrobun build + + - name: 📦 Upload Electrobun E2E App Bundle + uses: actions/upload-artifact@v4 + with: + name: electrobun-e2e-bundle-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/electrobun-e2e-bundle.tgz + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/_ci-build-electrobun-package-app.reusable.yml b/.github/workflows/_ci-build-electrobun-package-app.reusable.yml new file mode 100644 index 000000000..d41e7803b --- /dev/null +++ b/.github/workflows/_ci-build-electrobun-package-app.reusable.yml @@ -0,0 +1,133 @@ +name: Build Electrobun Package Test App +# Description: Builds the Electrobun package-test fixture (CEF bundle) and uploads it as a +# symlink-preserving tarball for the package-test job to install the packed service into. +# +# Mirrors the Electrobun e2e-app build: no Rust, Bun + bundled CEF (~150MB download), and a +# tar artifact (NOT the zip-based upload-archive) because the macOS .app carries CEF +# framework symlinks that `zip -r` would resolve/duplicate. macOS-only — the service is +# macOS-only in 0.x (Linux/Windows CEF is upstream-blocked; see @wdio/electrobun-service). + +permissions: + contents: read + +on: + workflow_call: + inputs: + os: + description: 'OS of runner' + required: true + type: string + build_id: + description: 'Build ID from the main build job' + type: string + required: false + artifact_size: + description: 'Size of the build artifact in bytes' + type: string + required: false + cache_key: + description: 'Cache key to use for downloading artifacts' + type: string + required: false + outputs: + build_id: + description: 'Unique identifier for this build' + value: ${{ jobs.build-electrobun-package-app.outputs.build_id }} + build_date: + description: 'Timestamp when the build completed' + value: ${{ jobs.build-electrobun-package-app.outputs.build_date }} + +env: + TURBO_TELEMETRY_DISABLED: 1 + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + +jobs: + build-electrobun-package-app: + name: Build + runs-on: ${{ inputs.os }} + outputs: + build_id: ${{ steps.build-info.outputs.build_id }} + build_date: ${{ steps.build-info.outputs.build_date }} + steps: + - name: 👷 Checkout Repository + uses: actions/checkout@v6 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + + - name: 🛠️ Setup Development Environment + uses: ./.github/workflows/actions/setup-workspace + with: + node-version: '24' + + - name: 🥟 Setup Bun + uses: oven-sh/setup-bun@v2 + with: + # Pinned for reproducibility — the beta CEF toolchain is fragile, so a + # silent Bun bump must not break `electrobun build`. Bump deliberately. + bun-version: '1.3.14' + + - name: 📊 Generate Build Information + id: build-info + shell: bash + run: | + echo "build_id=$(date +%s)-${{ github.run_id }}-${{ runner.os }}" >> "$GITHUB_OUTPUT" + echo "build_date=$(date -u +"%Y-%m-%dT%H:%M:%SZ")" >> "$GITHUB_OUTPUT" + + - name: 📦 Download Package Build Artifacts + uses: ./.github/workflows/actions/download-archive + with: + name: wdio-desktop-mobile + path: wdio-desktop-mobile-build + filename: artifact.zip + cache_key_prefix: wdio-desktop-build + exact_cache_key: ${{ inputs.cache_key || github.run_id && format('{0}-{1}-{2}-{3}{4}', 'Linux', 'wdio-desktop-build', 'wdio-desktop-mobile', github.run_id, github.run_attempt > 1 && format('-rerun{0}', github.run_attempt) || '') || '' }} + + - name: 📊 Show Build Information + if: inputs.build_id != '' && inputs.artifact_size != '' + shell: bash + run: | + echo "::notice::Build artifact: ID=${{ inputs.build_id }}, Size=${{ inputs.artifact_size }} bytes" + + - name: 📦 Install Dependencies + run: pnpm install --frozen-lockfile + shell: bash + + # CEF download + bundle (~150MB) — the beta Bun/CEF toolchain is the biggest + # unknown here; a failure is most likely a toolchain/network issue, not a fixture + # regression. + - name: 🏗️ Build Electrobun Package Test App + run: pnpm run build + shell: bash + working-directory: fixtures/package-tests/electrobun-app + + - name: 🔎 Locate Built Bundle + shell: bash + run: | + echo "Build output tree:" + ls -R fixtures/package-tests/electrobun-app/build || true + if [ ! -d "fixtures/package-tests/electrobun-app/build" ]; then + echo "::error::Electrobun package-app build directory was not produced." + exit 1 + fi + + # tar (not the shared zip-based upload-archive): the macOS .app carries CEF framework + # symlinks that `zip -r` resolves/duplicates. A symlink-preserving tarball shared via + # actions/upload-artifact round-trips the bundle intact (matches the e2e build). + - name: 🗜️ Archive bundle (tar preserves .app framework symlinks) + shell: bash + run: | + # On Windows, $RUNNER_TEMP is a drive path (D:\a\_temp) and GNU tar reads the + # `D:` as a remote host ("Cannot connect to D:") — --force-local treats it as + # a local path. macOS bsdtar lacks that flag, so only pass it on Windows. + flags="" + [ "$RUNNER_OS" = "Windows" ] && flags="--force-local" + tar $flags -czpf "${RUNNER_TEMP}/electrobun-package-app.tgz" -C fixtures/package-tests/electrobun-app build + + - name: 📦 Upload Electrobun Package Test App Bundle + uses: actions/upload-artifact@v4 + with: + name: electrobun-package-app-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/electrobun-package-app.tgz + retention-days: 1 + if-no-files-found: error diff --git a/.github/workflows/_ci-detect-changes.reusable.yml b/.github/workflows/_ci-detect-changes.reusable.yml index 4591f5cfb..51a9bd93c 100644 --- a/.github/workflows/_ci-detect-changes.reusable.yml +++ b/.github/workflows/_ci-detect-changes.reusable.yml @@ -21,6 +21,9 @@ on: run_dioxus: description: 'Whether to run Dioxus-related tests and builds' value: ${{ jobs.detect.outputs.run_dioxus }} + run_electrobun: + description: 'Whether to run Electrobun-related tests and builds' + value: ${{ jobs.detect.outputs.run_electrobun }} has_shared_changes: description: 'Whether shared packages changed (affects both services)' value: ${{ jobs.detect.outputs.has_shared_changes }} @@ -36,6 +39,7 @@ jobs: run_electron: ${{ steps.determine.outputs.run_electron || 'false' }} run_tauri: ${{ steps.determine.outputs.run_tauri || 'false' }} run_dioxus: ${{ steps.determine.outputs.run_dioxus || 'false' }} + run_electrobun: ${{ steps.determine.outputs.run_electrobun || 'false' }} has_shared_changes: ${{ steps.determine.outputs.has_shared_changes || 'false' }} run_lint_only: ${{ steps.determine.outputs.run_lint_only || 'false' }} @@ -70,6 +74,11 @@ jobs: - 'packages/dioxus-bridge/**' - 'packages/dioxus-driver/**' + # Electrobun service packages (CDP-attach, no Rust) + electrobun_service: + - 'packages/electrobun-service/**' + - 'packages/electrobun-cdp-bridge/**' + # Shared packages (affect all services) shared: - 'packages/native-types/**' @@ -89,6 +98,9 @@ jobs: - 'e2e/test/dioxus/**' - 'e2e/wdio.dioxus.conf.ts' - 'e2e/wdio.dioxus-embedded.conf.ts' + e2e_electrobun: + - 'e2e/test/electrobun/**' + - 'e2e/wdio.electrobun.conf.ts' # Test fixtures and apps fixtures_electron: @@ -100,6 +112,9 @@ jobs: fixtures_dioxus: - 'fixtures/e2e-apps/dioxus/**' - 'fixtures/package-tests/dioxus-app/**' + fixtures_electrobun: + - 'fixtures/e2e-apps/electrobun/**' + - 'fixtures/package-tests/electrobun-app/**' # Shared infrastructure — triggers all service pipelines. # Service-specific workflow files are split into infra_{electron,tauri,dioxus} @@ -163,6 +178,12 @@ jobs: - '.github/workflows/_ci-e2e-dioxus-all-providers.reusable.yml' - '.github/workflows/_ci-package-docker-dioxus.reusable.yml' + # Electrobun-only workflow files (no -crates: Electrobun has no Rust) + infra_electrobun: + - '.github/workflows/_ci-build-electrobun-e2e-app.reusable.yml' + - '.github/workflows/_ci-build-electrobun-package-app.reusable.yml' + - '.github/workflows/_ci-e2e-electrobun-all-providers.reusable.yml' + - name: Determine What to Run id: determine run: | @@ -172,6 +193,7 @@ jobs: echo "run_electron=true" >> "$GITHUB_OUTPUT" echo "run_tauri=true" >> "$GITHUB_OUTPUT" echo "run_dioxus=true" >> "$GITHUB_OUTPUT" + echo "run_electrobun=true" >> "$GITHUB_OUTPUT" echo "has_shared_changes=true" >> "$GITHUB_OUTPUT" echo "::notice::Force all mode enabled - running all tests" exit 0 @@ -182,33 +204,41 @@ jobs: echo "electron_service=${{ steps.changes.outputs.electron_service }}" echo "tauri_service=${{ steps.changes.outputs.tauri_service }}" echo "dioxus_service=${{ steps.changes.outputs.dioxus_service }}" + echo "electrobun_service=${{ steps.changes.outputs.electrobun_service }}" echo "shared=${{ steps.changes.outputs.shared }}" echo "e2e_electron=${{ steps.changes.outputs.e2e_electron }}" echo "e2e_tauri=${{ steps.changes.outputs.e2e_tauri }}" echo "e2e_dioxus=${{ steps.changes.outputs.e2e_dioxus }}" + echo "e2e_electrobun=${{ steps.changes.outputs.e2e_electrobun }}" echo "fixtures_electron=${{ steps.changes.outputs.fixtures_electron }}" echo "fixtures_tauri=${{ steps.changes.outputs.fixtures_tauri }}" echo "fixtures_dioxus=${{ steps.changes.outputs.fixtures_dioxus }}" + echo "fixtures_electrobun=${{ steps.changes.outputs.fixtures_electrobun }}" echo "infra=${{ steps.changes.outputs.infra }}" echo "infra_electron=${{ steps.changes.outputs.infra_electron }}" echo "infra_tauri=${{ steps.changes.outputs.infra_tauri }}" echo "infra_dioxus=${{ steps.changes.outputs.infra_dioxus }}" + echo "infra_electrobun=${{ steps.changes.outputs.infra_electrobun }}" DOCS=${{ steps.changes.outputs.docs }} ELECTRON=${{ steps.changes.outputs.electron_service }} TAURI=${{ steps.changes.outputs.tauri_service }} DIOXUS=${{ steps.changes.outputs.dioxus_service }} + ELECTROBUN=${{ steps.changes.outputs.electrobun_service }} SHARED=${{ steps.changes.outputs.shared }} E2E_ELECTRON=${{ steps.changes.outputs.e2e_electron }} E2E_TAURI=${{ steps.changes.outputs.e2e_tauri }} E2E_DIOXUS=${{ steps.changes.outputs.e2e_dioxus }} + E2E_ELECTROBUN=${{ steps.changes.outputs.e2e_electrobun }} FIXTURES_ELECTRON=${{ steps.changes.outputs.fixtures_electron }} FIXTURES_TAURI=${{ steps.changes.outputs.fixtures_tauri }} FIXTURES_DIOXUS=${{ steps.changes.outputs.fixtures_dioxus }} + FIXTURES_ELECTROBUN=${{ steps.changes.outputs.fixtures_electrobun }} INFRA=${{ steps.changes.outputs.infra }} INFRA_ELECTRON=${{ steps.changes.outputs.infra_electron }} INFRA_TAURI=${{ steps.changes.outputs.infra_tauri }} INFRA_DIOXUS=${{ steps.changes.outputs.infra_dioxus }} + INFRA_ELECTROBUN=${{ steps.changes.outputs.infra_electrobun }} # Electron: electron_service || e2e_electron || fixtures_electron || shared || infra || infra_electron RUN_ELECTRON=$(echo "$ELECTRON $E2E_ELECTRON $FIXTURES_ELECTRON $SHARED $INFRA $INFRA_ELECTRON" | grep -o 'true' | head -1 || echo "") @@ -228,10 +258,16 @@ jobs: RUN_DIOXUS="false" fi + # Electrobun: electrobun_service || e2e_electrobun || fixtures_electrobun || shared || infra || infra_electrobun + RUN_ELECTROBUN=$(echo "$ELECTROBUN $E2E_ELECTROBUN $FIXTURES_ELECTROBUN $SHARED $INFRA $INFRA_ELECTROBUN" | grep -o 'true' | head -1 || echo "") + if [ -z "$RUN_ELECTROBUN" ]; then + RUN_ELECTROBUN="false" + fi + # Lint-only mode when no service-impacting changes detected # Covers docs-only PRs, config-only edits outside infra, etc. RUN_LINT_ONLY="false" - if [ "$RUN_ELECTRON" = "false" ] && [ "$RUN_TAURI" = "false" ] && [ "$RUN_DIOXUS" = "false" ]; then + if [ "$RUN_ELECTRON" = "false" ] && [ "$RUN_TAURI" = "false" ] && [ "$RUN_DIOXUS" = "false" ] && [ "$RUN_ELECTROBUN" = "false" ]; then RUN_LINT_ONLY="true" fi @@ -239,6 +275,7 @@ jobs: echo "run_electron=$RUN_ELECTRON" >> "$GITHUB_OUTPUT" echo "run_tauri=$RUN_TAURI" >> "$GITHUB_OUTPUT" echo "run_dioxus=$RUN_DIOXUS" >> "$GITHUB_OUTPUT" + echo "run_electrobun=$RUN_ELECTROBUN" >> "$GITHUB_OUTPUT" echo "has_shared_changes=$SHARED" >> "$GITHUB_OUTPUT" echo "run_lint_only=$RUN_LINT_ONLY" >> "$GITHUB_OUTPUT" @@ -252,28 +289,34 @@ jobs: echo "| Electron service | $ELECTRON |" >> "$GITHUB_STEP_SUMMARY" echo "| Tauri service | $TAURI |" >> "$GITHUB_STEP_SUMMARY" echo "| Dioxus service | $DIOXUS |" >> "$GITHUB_STEP_SUMMARY" + echo "| Electrobun service | $ELECTROBUN |" >> "$GITHUB_STEP_SUMMARY" echo "| Shared packages | $SHARED |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E Electron | $E2E_ELECTRON |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E Tauri | $E2E_TAURI |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E Dioxus | $E2E_DIOXUS |" >> "$GITHUB_STEP_SUMMARY" + echo "| E2E Electrobun | $E2E_ELECTROBUN |" >> "$GITHUB_STEP_SUMMARY" echo "| Fixtures Electron | $FIXTURES_ELECTRON |" >> "$GITHUB_STEP_SUMMARY" echo "| Fixtures Tauri | $FIXTURES_TAURI |" >> "$GITHUB_STEP_SUMMARY" echo "| Fixtures Dioxus | $FIXTURES_DIOXUS |" >> "$GITHUB_STEP_SUMMARY" + echo "| Fixtures Electrobun | $FIXTURES_ELECTROBUN |" >> "$GITHUB_STEP_SUMMARY" echo "| Infrastructure (shared) | $INFRA |" >> "$GITHUB_STEP_SUMMARY" echo "| Infrastructure (Electron) | $INFRA_ELECTRON |" >> "$GITHUB_STEP_SUMMARY" echo "| Infrastructure (Tauri) | $INFRA_TAURI |" >> "$GITHUB_STEP_SUMMARY" echo "| Infrastructure (Dioxus) | $INFRA_DIOXUS |" >> "$GITHUB_STEP_SUMMARY" + echo "| Infrastructure (Electrobun) | $INFRA_ELECTROBUN |" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "### Decisions" >> "$GITHUB_STEP_SUMMARY" echo "- **Run Electron**: $RUN_ELECTRON" >> "$GITHUB_STEP_SUMMARY" echo "- **Run Tauri**: $RUN_TAURI" >> "$GITHUB_STEP_SUMMARY" echo "- **Run Dioxus**: $RUN_DIOXUS" >> "$GITHUB_STEP_SUMMARY" + echo "- **Run Electrobun**: $RUN_ELECTROBUN" >> "$GITHUB_STEP_SUMMARY" echo "- **Lint only (no service changes)**: $RUN_LINT_ONLY" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" echo "### Outputs" >> "$GITHUB_STEP_SUMMARY" echo "- \`run_electron\`: $RUN_ELECTRON" >> "$GITHUB_STEP_SUMMARY" echo "- \`run_tauri\`: $RUN_TAURI" >> "$GITHUB_STEP_SUMMARY" echo "- \`run_dioxus\`: $RUN_DIOXUS" >> "$GITHUB_STEP_SUMMARY" + echo "- \`run_electrobun\`: $RUN_ELECTROBUN" >> "$GITHUB_STEP_SUMMARY" echo "- \`has_shared_changes\`: $SHARED" >> "$GITHUB_STEP_SUMMARY" echo "- \`run_lint_only\`: $RUN_LINT_ONLY" >> "$GITHUB_STEP_SUMMARY" @@ -283,19 +326,24 @@ jobs: echo " Electron service: $ELECTRON" echo " Tauri service: $TAURI" echo " Dioxus service: $DIOXUS" + echo " Electrobun service: $ELECTROBUN" echo " Shared packages: $SHARED" echo " E2E Electron: $E2E_ELECTRON" echo " E2E Tauri: $E2E_TAURI" echo " E2E Dioxus: $E2E_DIOXUS" + echo " E2E Electrobun: $E2E_ELECTROBUN" echo " Fixtures Electron: $FIXTURES_ELECTRON" echo " Fixtures Tauri: $FIXTURES_TAURI" echo " Fixtures Dioxus: $FIXTURES_DIOXUS" + echo " Fixtures Electrobun: $FIXTURES_ELECTROBUN" echo " Infrastructure (shared): $INFRA" echo " Infrastructure (Electron): $INFRA_ELECTRON" echo " Infrastructure (Tauri): $INFRA_TAURI" echo " Infrastructure (Dioxus): $INFRA_DIOXUS" + echo " Infrastructure (Electrobun): $INFRA_ELECTROBUN" echo "" echo " Run Electron: $RUN_ELECTRON" echo " Run Tauri: $RUN_TAURI" echo " Run Dioxus: $RUN_DIOXUS" + echo " Run Electrobun: $RUN_ELECTROBUN" echo " Run lint only: $RUN_LINT_ONLY" diff --git a/.github/workflows/_ci-e2e-electrobun-all-providers.reusable.yml b/.github/workflows/_ci-e2e-electrobun-all-providers.reusable.yml new file mode 100644 index 000000000..b91aedf18 --- /dev/null +++ b/.github/workflows/_ci-e2e-electrobun-all-providers.reusable.yml @@ -0,0 +1,244 @@ +name: Electrobun E2E Tests + +# Description: Runs Electrobun E2E tests. Electrobun is a single-provider, CDP-attach +# framework (like Electron) — there is no driver-provider matrix. The reusable name +# keeps the `*-all-providers` suffix for consistency with the tauri/dioxus workflows. + +permissions: + contents: read + +on: + workflow_call: + inputs: + os: + description: 'Operating system to run tests on' + required: true + type: string + node-version: + description: 'Node.js version to use for testing' + required: true + type: string + test-type: + description: 'Test type (standard, window, deeplink)' + type: string + default: 'standard' + build_id: + description: 'Build ID from the build job' + type: string + required: false + artifact_size: + description: 'Size of the build artifact in bytes' + type: string + required: false + cache_key: + description: 'Cache key to use for downloading package artifacts' + type: string + required: false + +env: + TURBO_TELEMETRY_DISABLED: 1 + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + +jobs: + e2e-electrobun: + name: Run + runs-on: ${{ inputs.os }} + steps: + - name: 👷 Checkout Repository + uses: actions/checkout@v6 + with: + ssh-key: ${{ secrets.DEPLOY_KEY }} + + - name: 🛠️ Setup Development Environment + uses: ./.github/workflows/actions/setup-workspace + with: + node-version: ${{ inputs.node-version }} + + # CEF + native-wrapper runtime dependencies on Linux. libwebkit2gtk-4.1-0 is + # required even though we use the CEF renderer: electrobun's libNativeWrapper.so + # links webkit2gtk (it supports both renderers), so it won't dlopen without it + # ("libwebkit2gtk-4.1.so.0: cannot open shared object file"). The rest are CEF's + # Chromium runtime libs. + - name: 🐧 Install CEF Runtime Dependencies (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + xvfb \ + libwebkit2gtk-4.1-0 \ + libayatana-appindicator3-1 \ + libnss3 \ + libatk1.0-0 \ + libatk-bridge2.0-0 \ + libcups2 \ + libdrm2 \ + libgbm1 \ + libgtk-3-0 \ + libasound2t64 \ + libxkbcommon0 \ + libxdamage1 \ + libxrandr2 \ + libxcomposite1 \ + libxfixes3 || true + + - name: 📦 Download Package Build Artifacts + uses: ./.github/workflows/actions/download-archive + with: + name: wdio-desktop-mobile + path: wdio-desktop-mobile-build + filename: artifact.zip + cache_key_prefix: wdio-desktop-build + exact_cache_key: ${{ inputs.cache_key || github.run_id && format('{0}-{1}-{2}-{3}{4}', 'Linux', 'wdio-desktop-build', 'wdio-desktop-mobile', github.run_id, github.run_attempt > 1 && format('-rerun{0}', github.run_attempt) || '') || '' }} + + # Symlink-preserving tarball (see the build workflow) — NOT the shared + # zip/dist-target download-archive, which can't round-trip the CEF .app. + - name: 📦 Download Electrobun E2E App Bundle + uses: actions/download-artifact@v4 + with: + name: electrobun-e2e-bundle-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }} + + - name: 🗜️ Extract Electrobun Bundle + shell: bash + run: | + mkdir -p fixtures/e2e-apps/electrobun + # Windows: --force-local so GNU tar reads D:\… as a path, not a remote host + # (see the build workflow). macOS bsdtar lacks the flag, so Windows-only. + flags="" + [ "$RUNNER_OS" = "Windows" ] && flags="--force-local" + tar $flags -xzpf "${RUNNER_TEMP}/electrobun-e2e-bundle.tgz" -C fixtures/e2e-apps/electrobun + + - name: ✅ Verify Electrobun Bundle + shell: bash + run: | + if [ ! -d "fixtures/e2e-apps/electrobun/build" ]; then + echo "::error::Electrobun build dir not found — bundle was not extracted correctly." + find fixtures/e2e-apps/electrobun/ -maxdepth 1 || true + exit 1 + fi + echo "Electrobun build tree (truncated):" + # `… | head` closes the pipe early; under `set -o pipefail -e` the upstream + # `find` then dies with SIGPIPE (141) and fails the step — so tolerate it. + find fixtures/e2e-apps/electrobun/build -maxdepth 3 2>/dev/null | head -50 || true + + - name: 📊 Show Build Information + if: inputs.build_id != '' && inputs.artifact_size != '' + shell: bash + run: | + echo "::notice::Package build: ID=${{ inputs.build_id }}, Size=${{ inputs.artifact_size }} bytes" + echo "::notice::Electrobun bundle: Using pre-built bundle for ${{ runner.os }}" + + - name: 🪄 Generate Test Command + id: gen-test + uses: actions/github-script@v9 + env: + TEST_TYPE: ${{ inputs.test-type }} + with: + script: | + const ALLOWED = ['standard', 'window', 'deeplink']; + const testType = process.env.TEST_TYPE.trim(); + if (!ALLOWED.includes(testType)) { + core.setFailed(`Invalid test-type: "${testType}". Allowed: ${ALLOWED.join(', ')}`); + return; + } + const suffix = testType !== 'standard' ? `:${testType}` : ''; + core.setOutput('script', `test:e2e:electrobun${suffix}`); + + - name: 🧹 Cleanup + if: always() + shell: bash + run: | + # Match the per-worker clone temp-dir prefix (wdio-electrobun-bundle-*), + # which appears in every spawned process's argv — the launcher binary, the + # bun child, and CEF helper subprocesses — rather than the .app display + # name. The macOS bundle keeps spaces ("WDIO Electrobun E2E-dev.app") while + # the Windows exe name does not, so a name-based match misses the CEF + # helpers; the temp-dir prefix is stable across both. + if [ "${{ runner.os }}" == "Windows" ]; then + powershell -Command "Get-CimInstance Win32_Process | Where-Object { \$_.CommandLine -like '*wdio-electrobun-bundle*' } | ForEach-Object { Stop-Process -Id \$_.ProcessId -Force -ErrorAction SilentlyContinue }" || true + else + pkill -9 -f 'wdio-electrobun-bundle' || true + fi + + - name: 🧪 E2E Tests + id: test + continue-on-error: true + shell: pwsh + working-directory: e2e + env: + TEST_TYPE: ${{ inputs.test-type }} + run: | + # autoXvfb in wdio.electrobun.conf.ts manages Xvfb on Linux (CDP-attach, + # like Electron — no xvfb-run wrap needed). + pnpm run ${{ steps.gen-test.outputs.script }} + + - name: 🧹 Post-cleanup + if: always() + shell: bash + run: | + # Match the per-worker clone temp-dir prefix (wdio-electrobun-bundle-*), + # which appears in every spawned process's argv — the launcher binary, the + # bun child, and CEF helper subprocesses — rather than the .app display + # name. The macOS bundle keeps spaces ("WDIO Electrobun E2E-dev.app") while + # the Windows exe name does not, so a name-based match misses the CEF + # helpers; the temp-dir prefix is stable across both. + if [ "${{ runner.os }}" == "Windows" ]; then + powershell -Command "Get-CimInstance Win32_Process | Where-Object { \$_.CommandLine -like '*wdio-electrobun-bundle*' } | ForEach-Object { Stop-Process -Id \$_.ProcessId -Force -ErrorAction SilentlyContinue }" || true + else + pkill -9 -f 'wdio-electrobun-bundle' || true + fi + + - name: 🐛 Debug Information + if: always() + shell: bash + run: pnpm exec tsx e2e/scripts/show-logs.ts || true + + - name: 📊 E2E Summary + if: always() + shell: bash + run: | + # shellcheck disable=SC2129 + echo "## Electrobun E2E Results — ${{ inputs.os }} / ${{ inputs.test-type }}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Result |" >> "$GITHUB_STEP_SUMMARY" + echo "|--------|" >> "$GITHUB_STEP_SUMMARY" + case "${{ steps.test.outcome }}" in + success) echo "| ✅ Passed |" >> "$GITHUB_STEP_SUMMARY" ;; + failure) echo "| ❌ Failed |" >> "$GITHUB_STEP_SUMMARY" ;; + cancelled) echo "| ⚠️ Cancelled |" >> "$GITHUB_STEP_SUMMARY" ;; + *) echo "| ⏭️ ${{ steps.test.outcome }} |" >> "$GITHUB_STEP_SUMMARY" ;; + esac + + - name: 📦 Upload Test Logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: e2e-electrobun-logs-${{ inputs.os }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ inputs.test-type }} + path: e2e/logs/**/*.log + retention-days: 90 + if-no-files-found: warn + + - name: ❌ Check Result + if: always() + shell: bash + run: | + case "${{ steps.test.outcome }}" in + success|skipped|"") + echo "Electrobun E2E passed" + ;; + failure) + echo "::error::Electrobun E2E tests failed" + exit 1 + ;; + cancelled) + echo "::error::Electrobun E2E tests were cancelled" + exit 1 + ;; + *) + echo "::error::Electrobun E2E had unexpected outcome: ${{ steps.test.outcome }}" + exit 1 + ;; + esac diff --git a/.github/workflows/_ci-package.reusable.yml b/.github/workflows/_ci-package.reusable.yml index 04ba6c8c6..00d1bd0c5 100644 --- a/.github/workflows/_ci-package.reusable.yml +++ b/.github/workflows/_ci-package.reusable.yml @@ -14,10 +14,10 @@ on: type: string required: true service: - description: 'Service to test (electron, tauri, dioxus, or both)' + description: 'Service to test (electron, tauri, dioxus, electrobun, or all)' type: string required: false - default: 'both' + default: 'all' module-type: description: 'Module type for Electron package tests (cjs, esm, or both)' type: string @@ -70,7 +70,7 @@ jobs: # Install Rust toolchain for auto-installing tauri-driver if testing Tauri service # The @wdio/tauri-service will automatically install tauri-driver via cargo if not found - name: 🦀 Setup Rust (for tauri-driver auto-install) - if: inputs.service == 'tauri' || inputs.service == 'both' + if: inputs.service == 'tauri' || inputs.service == 'all' shell: bash run: rustup update stable && rustup default stable && rustup component add rustfmt @@ -91,7 +91,7 @@ jobs: # Install WebKit WebDriver and GTK/X11 runtime dependencies for Linux (required by tauri-driver) # Note: Build dependencies are handled in the build workflows, we only need runtime libs for testing - name: 🌐 Install WebKit WebDriver and GTK/X11 Runtime Dependencies (Linux) - if: (inputs.service == 'tauri' || inputs.service == 'both') && runner.os == 'Linux' + if: (inputs.service == 'tauri' || inputs.service == 'all') && runner.os == 'Linux' shell: bash run: | echo "Installing WebKit WebDriver and GTK/X11 runtime dependencies for Linux..." @@ -137,7 +137,7 @@ jobs: exact_cache_key: ${{ inputs.dioxus_cache_key || '' }} - name: 📦 Download Tauri Package Test App Binary - if: inputs.service == 'tauri' || inputs.service == 'both' + if: inputs.service == 'tauri' || inputs.service == 'all' uses: ./.github/workflows/actions/download-archive with: name: tauri-package-app-${{ runner.os }}-${{ runner.arch == 'ARM64' && 'ARM64' || 'x64' }} @@ -146,6 +146,28 @@ jobs: cache_key_prefix: tauri-package-app exact_cache_key: ${{ inputs.tauri_cache_key || '' }} + # Electrobun is a CEF/Bun build whose macOS .app carries framework symlinks; it + # ships as a symlink-preserving tarball via actions/upload-artifact (NOT the + # zip-based download-archive used above), mirroring the e2e flow. macOS-only. + - name: 📦 Download Electrobun Package Test App Bundle + if: inputs.service == 'electrobun' + uses: actions/download-artifact@v4 + with: + name: electrobun-package-app-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }} + + - name: 🗜️ Extract Electrobun Bundle + if: inputs.service == 'electrobun' + shell: bash + run: | + mkdir -p fixtures/package-tests/electrobun-app + tar -xzpf "${RUNNER_TEMP}/electrobun-package-app.tgz" -C fixtures/package-tests/electrobun-app + if [ ! -d "fixtures/package-tests/electrobun-app/build" ]; then + echo "::error::Electrobun package-app build dir not found — bundle was not extracted correctly." + find fixtures/package-tests/electrobun-app/ -maxdepth 1 || true + exit 1 + fi + # Verify the extracted dist directories exist - name: 🔍 Verify Extracted Files id: verify-build @@ -179,7 +201,7 @@ jobs: done # Pack Electron service and dependencies if needed - if [ "$SERVICE" == "electron" ] || [ "$SERVICE" == "both" ]; then + if [ "$SERVICE" == "electron" ] || [ "$SERVICE" == "all" ]; then if [ -d "packages/electron-cdp-bridge/dist" ]; then (cd packages/electron-cdp-bridge && pnpm pack) fi @@ -189,7 +211,7 @@ jobs: fi # Pack Tauri service and plugin if needed - if [ "$SERVICE" == "tauri" ] || [ "$SERVICE" == "both" ]; then + if [ "$SERVICE" == "tauri" ] || [ "$SERVICE" == "all" ]; then if [ -d "packages/tauri-plugin/dist-js" ]; then (cd packages/tauri-plugin && pnpm pack) fi @@ -205,6 +227,16 @@ jobs: fi fi + # Pack Electrobun service + its CDP bridge if needed + if [ "$SERVICE" == "electrobun" ]; then + if [ -d "packages/electrobun-cdp-bridge/dist" ]; then + (cd packages/electrobun-cdp-bridge && pnpm pack) + fi + if [ -d "packages/electrobun-service/dist" ]; then + (cd packages/electrobun-service && pnpm pack) + fi + fi + # Run package tests using test-package.ts script # All apps are built in isolated environments (Electron always, Tauri if not skipBuild) # Tauri uses skipBuild to copy pre-built binaries from separate build jobs @@ -244,7 +276,7 @@ jobs: pnpm run test:package:tauri -- --skip-build fi ;; - both) + all) if [[ "${{ runner.os }}" == "macOS" ]]; then echo "⚠️ Skipping Tauri package tests on macOS (not supported)" echo "Module type: ${{ inputs.module-type }}" @@ -261,6 +293,11 @@ jobs: pnpm run test:package:tauri -- --skip-build fi ;; + electrobun) + # macOS-only (CEF). Uses the pre-built bundle extracted above; no xvfb (macOS). + echo "Running Electrobun package tests (macOS-only; using the pre-built CEF bundle)" + pnpm run test:package:electrobun -- --skip-build + ;; *) echo "Invalid service: $SERVICE" exit 1 diff --git a/.github/workflows/_release.reusable.yml b/.github/workflows/_release.reusable.yml index 4c2d52c7d..191af54d4 100644 --- a/.github/workflows/_release.reusable.yml +++ b/.github/workflows/_release.reusable.yml @@ -27,7 +27,7 @@ on: type: boolean default: false scope: - description: 'Release scope (electron, tauri, dioxus, utils, types, spy, core, shared) — controls build and toolchain steps' + description: 'Release scope (electron, tauri, dioxus, electrobun, utils, types, spy, core, shared) — controls build and toolchain steps' required: true type: string secrets: @@ -111,6 +111,9 @@ jobs: dioxus) echo "packages=@wdio/dioxus-service,@wdio/dioxus-bridge,wdio-dioxus-bridge,wdio-dioxus-embedded-driver,wdio-dioxus-driver" >> "$GITHUB_OUTPUT" ;; + electrobun) + echo "packages=@wdio/electrobun-service,@wdio/electrobun-cdp-bridge" >> "$GITHUB_OUTPUT" + ;; utils) echo "packages=@wdio/native-utils" >> "$GITHUB_OUTPUT" ;; @@ -189,6 +192,13 @@ jobs: --filter='@wdio/dioxus-bridge...' pnpm turbo run build:rust --filter='@wdio/dioxus-bridge' ;; + electrobun) + # CDP service like electron — no Rust/build:rust step. + pnpm turbo run build \ + --filter='@wdio/electrobun-service...' \ + --filter='@wdio/electrobun-cdp-bridge...' \ + --filter='@wdio/bundler...' + ;; utils) pnpm turbo run build --filter='@wdio/native-utils...' ;; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc4cc8662..029029c1e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -168,6 +168,43 @@ jobs: artifact_size: ${{ needs.build.outputs.artifact_size }} cache_key: ${{ needs.build.outputs.cache_key }} + # ──────────────────────────────────────────────────────────────────────── + # Electrobun E2E app (CEF bundle, no Rust crates). The CEF fixture build is + # the biggest unknown in this pipeline: a beta Bun/CEF toolchain that pulls a + # ~150MB CEF download. macOS is the validated platform. + # + # macOS-ONLY in 0.x (pre-1.0): Linux/Windows build + e2e are NOT run. They build + # fine, but their CEF can't recover from the chrome-runtime persist:default + # profile failure (the global-context fallback serves no /json), so the e2e can't + # attach — an upstream electrobun limitation (see the agent-os plan "Framework + # gaps"). Re-add the Linux/Windows build + e2e jobs once that lands upstream (#320). + # ──────────────────────────────────────────────────────────────────────── + build-electrobun-e2e-app-macos-arm: + name: Build Electrobun E2E App [macOS-ARM] + needs: [detect-changes, build] + if: needs.detect-changes.outputs.run_electrobun == 'true' && needs.detect-changes.outputs.run_lint_only != 'true' + uses: ./.github/workflows/_ci-build-electrobun-e2e-app.reusable.yml + secrets: inherit + with: + os: 'macos-latest' + build_id: ${{ needs.build.outputs.build_id }} + artifact_size: ${{ needs.build.outputs.artifact_size }} + cache_key: ${{ needs.build.outputs.cache_key }} + + # macOS-only (CEF). The package-test fixture build mirrors the e2e build (Bun/CEF, tar + # artifact). Linux/Windows are not built — the service is macOS-only in 0.x. + build-electrobun-package-app-macos-arm: + name: Build Electrobun Package Test App [macOS-ARM] + needs: [detect-changes, build] + if: needs.detect-changes.outputs.run_electrobun == 'true' && needs.detect-changes.outputs.run_lint_only != 'true' + uses: ./.github/workflows/_ci-build-electrobun-package-app.reusable.yml + secrets: inherit + with: + os: 'macos-latest' + build_id: ${{ needs.build.outputs.build_id }} + artifact_size: ${{ needs.build.outputs.artifact_size }} + cache_key: ${{ needs.build.outputs.cache_key }} + # Build Dioxus package test apps - only if Dioxus package tests will run build-dioxus-package-app-linux: name: Build Dioxus Package Test App [Linux] @@ -657,6 +694,47 @@ jobs: cache_key: ${{ needs.build.outputs.cache_key }} dioxus_cache_key: ${{ needs.build-dioxus-e2e-app-macos-arm.outputs.cache_key }} + # ──────────────────────────────────────────────────────────────────────── + # Electrobun E2E tests (single CDP-attach provider — no provider matrix). + # + # macOS-ONLY in 0.x (pre-1.0): only the macOS-ARM leg runs. Linux/Windows e2e are + # NOT run — their CEF can't recover from the persist:default profile failure (no + # /json), an upstream electrobun limitation (see the agent-os plan "Framework + # gaps"). Re-add a Linux/Windows leg (+ its build job above) once that lands (#320). + # + # Test-type matrix: 'standard' (api/application/execute/logging/mock), + # 'window' (two CEF views), 'deeplink' (macOS-only; auto-skips elsewhere). + # ──────────────────────────────────────────────────────────────────────── + # REQUIRED gate (listed in ci-status.needs): the `standard` SINGLE-WINDOW suite + # (api/application/execute/logging/mock). The window (multi-window) and deeplink + # suites are intentionally NOT run on CI: they hit 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, so multi-window/relaunch + # fall back to a racy global context, and a shared user-data-dir folds instances → + # no CDP targets. Not fixable from the fixture/service, and multiremote is blocked + # for the same reason. See the agent-os plan "Framework gaps" + the skip notes in + # window.spec.ts / deeplink.spec.ts. Re-add these (and a multiremote leg) once + # electrobun ships per-window partitions / per-instance root_cache_path / open-url + # routing (#320). The specs stay runnable locally via TEST_TYPE=window|deeplink. + e2e-electrobun-macos-arm: + name: E2E - Electrobun [macOS-ARM] - ${{ matrix.test-type }} + needs: [detect-changes, build, build-electrobun-e2e-app-macos-arm] + if: needs.detect-changes.outputs.run_electrobun == 'true' && needs.detect-changes.outputs.run_lint_only != 'true' + strategy: + fail-fast: false + matrix: + test-type: ['standard'] + uses: ./.github/workflows/_ci-e2e-electrobun-all-providers.reusable.yml + secrets: inherit + with: + os: 'macos-latest' + node-version: '24' + test-type: ${{ matrix.test-type }} + build_id: ${{ needs.build.outputs.build_id }} + artifact_size: ${{ needs.build.outputs.artifact_size }} + cache_key: ${{ needs.build.outputs.cache_key }} + + # Dioxus package tests - all platforms (mirrors E2E coverage) package-dioxus-linux: name: Package - Dioxus [Linux] @@ -758,6 +836,21 @@ jobs: with: distro: ${{ matrix.distro }} + # Electrobun package test — macOS-only (CEF). The fixture bundle is downloaded by + # artifact name within the run (tar/symlinks), so no cache_key is threaded. + package-electrobun-macos-arm: + name: Package - Electrobun [macOS-ARM] + needs: [detect-changes, build, build-electrobun-package-app-macos-arm] + if: needs.detect-changes.outputs.run_electrobun == 'true' && needs.detect-changes.outputs.run_lint_only != 'true' + uses: ./.github/workflows/_ci-package.reusable.yml + secrets: inherit + with: + os: 'macos-latest' + service: 'electrobun' + build_id: ${{ needs.build.outputs.build_id }} + artifact_size: ${{ needs.build.outputs.artifact_size }} + cache_key: ${{ needs.build.outputs.cache_key }} + # Universal binary verification - Electron only package-electron-mac-universal: name: Package - Electron [macOS-Universal-Verify] @@ -862,6 +955,11 @@ jobs: - e2e-dioxus-linux - e2e-dioxus-windows - e2e-dioxus-macos-arm + # Electrobun is macOS-only in 0.x: the required gate is the macOS `standard` + # single-window suite. Linux/Windows e2e (+ their build jobs) are not run at all + # (CEF can't serve /json there — upstream gap; see the macOS job note), and + # window/deeplink are not run anywhere (same gap). + - e2e-electrobun-macos-arm - package-electron-matrix - package-tauri-linux - package-tauri-windows @@ -875,6 +973,7 @@ jobs: - package-dioxus-macos-intel - package-dioxus-linux-arm - package-dioxus-distros + - package-electrobun-macos-arm - build-matrix - build-dioxus-crates-linux - build-tauri-e2e-app-linux @@ -886,6 +985,8 @@ jobs: - build-dioxus-e2e-app-linux - build-dioxus-e2e-app-windows - build-dioxus-e2e-app-macos-arm + - build-electrobun-e2e-app-macos-arm + - build-electrobun-package-app-macos-arm - build-dioxus-package-app-linux - build-dioxus-package-app-windows - build-dioxus-package-app-macos-arm diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f988552c9..2ce8d3624 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: inputs: scope: - description: "Release scope (electron, tauri, dioxus, utils, types, spy, core, shared)" + description: "Release scope (electron, tauri, dioxus, electrobun, utils, types, spy, core, shared)" required: true type: choice options: @@ -20,6 +20,7 @@ on: - electron - tauri - dioxus + - electrobun default: shared bump: description: "Version increment" diff --git a/AGENTS.md b/AGENTS.md index 7a9067404..50b4eb657 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ This is a monorepo providing WebdriverIO services for automated testing of nativ - **Electron** - `@wdio/electron-service` (v10.x) - **Tauri** - `@wdio/tauri-service` (v1.x) - **Dioxus** - `@wdio/dioxus-service` (v1.x) +- **Electrobun** - `@wdio/electrobun-service` (v0.1.x — **macOS-only**, requires the CEF renderer; Linux/Windows + multiremote/multi-window/deeplink are upstream-blocked, see the package README) **Planned:** React Native, Flutter, Capacitor, Neutralino. See [ROADMAP.md](./ROADMAP.md) for details. @@ -32,11 +33,13 @@ packages/ ├── electron-service/ # Electron WDIO service ├── tauri-service/ # Tauri WDIO service ├── dioxus-service/ # Dioxus WDIO service +├── electrobun-service/ # Electrobun WDIO service ├── tauri-plugin/ # Tauri v2 plugin (Rust + JS) ├── dioxus-bridge/ # Dioxus bridge crate (Rust) — IPC, mocking, log forwarding ├── dioxus-embedded-driver/ # Dioxus in-process WebDriver server (Rust) ├── dioxus-driver/ # Dioxus external WebDriver proxy (Rust, Windows 'external' provider) -├── electron-cdp-bridge/ # Chrome DevTools Protocol bridge +├── electron-cdp-bridge/ # CDP client — Electron main-process debugger (single target) +├── electrobun-cdp-bridge/ # CDP client — Electrobun CEF webviews (multi-target) ├── native-utils/ # Cross-platform utilities ├── native-types/ # TypeScript type definitions ├── native-spy/ # Spy utilities for mocking diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e5a8ee6ff..46d36a482 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -389,6 +389,7 @@ GitHub release notes are published per **user-installed** package — not per in | Electron | `@wdio/electron-service` | `@wdio/electron-cdp-bridge`, `@wdio/native-utils`, `@wdio/native-spy`, `@wdio/native-types` | | Tauri | `@wdio/tauri-service`, `tauri-plugin`, `tauri-plugin-webdriver` | — | | Dioxus | `@wdio/dioxus-service` | `wdio-dioxus-bridge`, `wdio-dioxus-embedded-driver`, `wdio-dioxus-driver` | +| Electrobun | `@wdio/electrobun-service` | `@wdio/electrobun-cdp-bridge`, `@wdio/native-utils`, `@wdio/native-spy`, `@wdio/native-types` | Tauri publishes three sets of release notes because `tauri-plugin` and `tauri-plugin-webdriver` are installed and configured directly by users in their Tauri app (Cargo dependency, capability/permission setup), so their breaking changes need their own changelog entries. Electron's and Dioxus's internal packages have no equivalent direct-install surface — users only wire in the bridge crate once and changes are transparent thereafter. diff --git a/README.md b/README.md index 1e51ab5e7..68627c943 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ [`@wdio/dioxus-service`](./packages/dioxus-service) - Dioxus desktop applications (Windows / macOS / Linux) +## Experimental Support + +> Early (`0.x`) support — scope is limited and the surface may change as upstream gaps are resolved. Not yet at parity with the frameworks above. + +

+
+ npm package + npm version + npm downloads +
+

+ +[`@wdio/electrobun-service`](./packages/electrobun-service) - Electrobun desktop applications — **experimental**, **macOS only** (`0.x`), requires the CEF renderer. Linux/Windows plus multiremote / multi-window / deeplink are upstream-blocked ([#317](https://github.com/webdriverio/desktop-mobile/issues/317)). See the [package README](./packages/electrobun-service). + ## Planned Support - **React Native** - Popular mobile and desktop framework @@ -73,7 +87,7 @@ See [ROADMAP.md](./ROADMAP.md) for detailed sequencing, os support, and timeline ## Features -- 🎯 **Framework-specific automation** - Native integration with Electron, Tauri, Dioxus +- 🎯 **Framework-specific automation** - Native integration with Electron, Tauri, Dioxus, Electrobun - 🔍 **Smart binary detection** - Automatic app discovery and configuration - 🎭 **API mocking & isolation** - Built-in mocking for deterministic tests - 🌍 **Browser-only test mode** - Run the renderer in Chrome against a dev server, no binary required. See the [Electron](./packages/electron-service/docs/browser-mode.md), [Tauri](./packages/tauri-service/docs/browser-mode.md), and [Dioxus](./packages/dioxus-service/docs/browser-mode.md) guides. @@ -88,7 +102,9 @@ desktop-mobile/ │ ├── electron-service/ # Electron service implementation │ ├── tauri-service/ # Tauri service implementation │ ├── dioxus-service/ # Dioxus service implementation -│ ├── electron-cdp-bridge/ # Chrome DevTools Protocol bridge +│ ├── electrobun-service/ # Electrobun service implementation +│ ├── electron-cdp-bridge/ # Chrome DevTools Protocol bridge (Electron) +│ ├── electrobun-cdp-bridge/ # Multi-target CDP bridge (Electrobun) │ ├── native-utils/ # Cross-platform utilities │ ├── native-types/ # TypeScript type definitions │ ├── native-spy/ # Spy utilities for mocking diff --git a/ROADMAP.md b/ROADMAP.md index 10d101989..0f3635a75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,10 +101,18 @@ The table below quantifies the key factors used to prioritise and sequence plann - Standard Appium WebView context switching - appPackage/appActivity capabilities -### Phase 5: Electrobun Desktop (Q2 2027) +### Phase 5: Electrobun Desktop — ✅ Shipped `0.1.0` (macOS-only) **Priority:** Medium - Emerging TypeScript-first desktop framework -**Target Platforms:** macOS 14+, Windows 11+, Linux (Ubuntu 22.04+) +> **Status:** `@wdio/electrobun-service` ships at **`0.1.0`, macOS-only**. CDP-attach via the +> CEF renderer works on macOS; **Linux/Windows** plus **multiremote / multi-window / deeplink** +> are blocked by an upstream CEF profile-isolation limitation (CEF can't create the forced +> `persist:default` profile and the global-context fallback serves no `/json` off macOS). +> Pre-1.0 by design — each upstream fix re-enables a platform/feature, graduating to `1.0` at +> full parity. Tracked in [#317](https://github.com/webdriverio/desktop-mobile/issues/317). +> The original plan follows. + +**Target Platforms:** macOS 14+ (shipped); Windows 11+ / Linux (Ubuntu 22.04+) blocked upstream **Why Electrobun:** - Growing momentum in the lightweight desktop space (~12MB bundles, sub-50ms startup) diff --git a/agent-os/specs/20260528-electrobun-service/RESEARCH_FINDINGS.md b/agent-os/specs/20260528-electrobun-service/RESEARCH_FINDINGS.md new file mode 100644 index 000000000..f1fbbab23 --- /dev/null +++ b/agent-os/specs/20260528-electrobun-service/RESEARCH_FINDINGS.md @@ -0,0 +1,107 @@ +# Electrobun Service Research — Phase 0 Spike Findings + +**Date:** May 28, 2026 +**Status:** SPIKE COMPLETE — macOS leg validated +**Goal:** Confirm a WebdriverIO CDP-attach service can drive a CEF-rendered Electrobun app, and pin down the architecture before the MVP PR. + +--- + +## 🎯 Executive Summary + +**RECOMMENDATION: ✅ GO (macOS validated) — CDP-attach + Chromedriver `debuggerAddress` is viable.** + +A throwaway CEF Electrobun app was built and driven end-to-end on macOS (darwin/arm64). Every load-bearing question passed: CEF serves CDP, Chromedriver attaches via `debuggerAddress` and drives elements, multiple windows enumerate as separate page targets, attach is observation-only (no `Page.navigate`), and macOS deeplinks reach the handler. The model mirrors `@wdio/electron-service`. + +The two biggest items for the MVP PR are **per-worker process isolation** (CEF is single-instance per `--user-data-dir`) and **Linux/Windows CDP availability** (only macOS validated here; Linux likely needs an upstream fix). + +### Environment validated +- macOS darwin/arm64 only. Bun 1.3.13, Node v24.14.0. +- Electrobun source `1.18.4-beta.3` (npm `latest` lags at `1.18.1`); CEF/Chromium `147.0.10 / 147.0.7727.118`. +- Chromedriver `147.0.7727.117` (Chrome for Testing, mac-arm64) — matching on **major 147** is what matters. + +--- + +## 📊 The six questions + +### 1. Per-OS CEF availability +- **macOS: ✅ validated.** CEF serves CDP. `nativeWrapper.mm:~5847` scans `FindAvailableRemoteDebugPort(9222,9232)` then sets `settings.remote_debugging_port`. +- **Linux: ⚠️ likely OFF.** `linux/nativeWrapper.cpp:~2386` has `settings.remote_debugging_port` **commented out** → a stock Linux CEF build may serve no `/json`. Validate in CI; likely needs an upstream patch or proof that `chromiumFlags` alone enables it. +- **Windows: ❓ untested.** +- The default **WebKit** renderer exposes no CDP on any OS — CEF-only. The service must require `bundleCEF: true` + `renderer: 'cef'`. + +### 2. Chromedriver-attach viability — ✅ YES +Chromedriver `147.0.7727.117` opened a session with `goog:chromeOptions.debuggerAddress: localhost:9333`, reported `browserVersion 147.0.7727.118`, returned **both windows as handles**, found `#increment-button`/`#counter`, clicked 3× (counter 0→3), and read `#app-title`. Same attach shape as `@wdio/electron-service`. Exact-patch chromedriver isn't published; **match on major 147**. + +### 3. Port control — ✅ PINNABLE (inverts the going-in assumption) +`mac.chromiumFlags: { "remote-debugging-port": "9333" }` pinned the port to **9333**, *overriding* the 9222–9232 auto-scan (9222 was free but unused). Mechanism: the wrapper both sets `CefSettings.remote_debugging_port` from the scan **and** appends `--remote-debugging-port` to the command line — **the command-line switch wins**. So the service CAN dictate the port, rather than only discover it. +- ⚠️ **Concurrency caveat:** a 2nd launch of the same bundle logged `Opening in existing browser session.` and spawned **no second CEF** — CEF is **single-instance per `--user-data-dir`**. Parallel WDIO workers each need a **distinct `--user-data-dir` + distinct pinned port**. + +### 4. Target enumeration shape +`/json` returned **2 targets, both `type:"page"`** — one per window (`views://mainview/index.html`, `views://secondview/index.html`). **No separate shell target**; each window/view is one content page. Discriminate by URL path under the custom `views://` scheme + title; in-page via `window.__electrobunWebviewId`/`WindowId` (main 1/1, second 2/2). Target IDs were **stable** across re-enumeration and equal to the handles Chromedriver returned. `Runtime.enable`/`evaluate`/`callFunctionOn` round-tripped **without `Page.navigate`**. + +### 5. In-webview IPC/RPC surface +Every CEF webview exposes: `__electrobun` (`receiveMessageFromBun`, `receiveInternalMessageFromBun`), `__electrobunBunBridge`, `__electrobunInternalBridge`, `__electrobunEventBridge`, `__electrobunSendToHost` (fn), `__electrobun_encrypt`/`_decrypt` (fns), `__electrobunWebviewId`, `__electrobunWindowId`, `__electrobunRpcSocketPort` (50000), `__electrobunSecretKeyBytes`. +- The **Bun backend bus IS reachable** from the webview via the host RPC WebSocket (`ws://localhost:<__electrobunRpcSocketPort>/socket?webviewId=`), but payloads are **AES-encrypted** with the secret key. +- **Tractable mock seam:** wrap `window.__electrobun.*` / `__electrobunSendToHost` before app code runs, via CDP `Page.addScriptToEvaluateOnNewDocument`. This surface differs materially from Electron's `ipcRenderer`/`contextBridge`. + +### 6. Deeplink — ✅ YES (macOS, warm start) +`open electrobun-playground://test/path?foo=bar` reached the handler with the full URL. Scheme registered in `Info.plist` `CFBundleURLTypes`. Handler API: `app.on("open-url", …)` (**named export** `import { app } from "electrobun/bun"`) or `Electrobun.events.on("open-url", …)`. Only warm-start exercised; cold-start + Linux/Windows untested. + +--- + +## 🚧 Blockers / must-handle for the MVP PR + +1. **Concurrent instances** *(biggest architectural item)* — one bundle = one CEF session per `--user-data-dir`; a 2nd launch folds into the 1st. Parallel/multiremote workers need a **per-worker `--user-data-dir` + distinct pinned `--remote-debugging-port`**. Confirm the port/user-data-dir can be passed at **launch time** (CLI/env), not only via build-time `electrobun.config.ts`. +2. **Linux CDP off by default** (`remote_debugging_port` commented out) and **Windows unverified** — validate both in CI; Linux likely needs an upstream patch or a confirmed `chromiumFlags` path. Until then, macOS + Windows lead, Linux is a documented follow-up. +3. **`1.18.4-beta.3` ships two defects** the spike had to patch around (pin a known-good release; don't rely on `file:` source linking): + - **B1:** dev `dist/` fallback only copies `main.js`, then `ENOENT dist/preload-full.js`. + - **B2 (hard crash):** the bundler renames `import {join}`→`join5` for the WGPU chunk but drops `dirname`, so the Bun worker dies on boot with `ReferenceError: dirname is not defined` in `findWgpuLibraryPath`. +4. **Version skew** — npm `electrobun` 1.18.1 lags source/CEF 1.18.4-beta.3 / Chromium 147. Pin compatible Electrobun + chromedriver (major 147) explicitly. + +## Launch-time control mechanism (PR2 source investigation) + +Followed up on blocker 1 by reading the macOS native wrapper + `chromium_flags.h`: + +- **Port — per-bundle, NOT a launch arg ⚠️ (source-inference corrected by empirical test).** The + source path suggested `CefMainArgs(argc, argv)` (`nativeWrapper.mm:5832`) would let a launch-time + `--remote-debugging-port` reach CEF — **but the shipped launcher disproves this.** The built + `Contents/Resources/main.js` only forwards identifier/name/channel into the native run-main-thread + entry; it never propagates process argv to CEF, and the port is read **exclusively** from + `Contents/Resources/build.json` `chromiumFlags.remote-debugging-port` by `libNativeWrapper.dylib` + at runtime (pinned 9333 + `--remote-debugging-port=9351` arg → CEF stayed on 9333; emptied config + + arg → fell back to 9222). **So the port is fixed per bundle.** To vary it per worker, give each + worker its own bundle copy (`cp -c`, APFS clone ≈ instant) with the port written into that copy's + `build.json` (no full rebuild — the dylib re-reads `build.json` at runtime). +- **Cache / user-data isolation — `CFFIXED_USER_HOME` works ✅.** `settings.root_cache_path` = + `buildAppDataPath(appSupport, identifier, channel, "CEF")` (`nativeWrapper.mm:5895–5908`), and + `NSApplicationSupportDirectory` resolves via `CFCopyHomeDirectoryURL()` which honors + **`CFFIXED_USER_HOME`**. Setting a distinct `CFFIXED_USER_HOME` per launch redirects each instance's + CEF cache to its own root (verified: real home stayed clean; `HOME` not required, harmless to also + set). This defeats CEF's single-instance-per-cache-root folding. + +**Implication for multiremote / per-worker parallelism — ACHIEVABLE on macOS, no upstream change.** +Empirically confirmed: with per-instance `CFFIXED_USER_HOME` + per-instance `build.json` port, two +concurrent same-app instances both served CDP and were independently driveable (`Runtime.evaluate` +`1+1`→`2` on both ports, 2 process trees, 2 distinct caches, zero "Opening in existing browser +session"). **Mechanism the launcher must implement:** per worker → clone the `.app` (APFS `cp -c`), +write a distinct allocated `remote-debugging-port` into the clone's `Contents/Resources/build.json`, +launch with a distinct `CFFIXED_USER_HOME` temp dir. Single-instance (MVP) is the same path with N=1. +(Linux/Windows isolation still unverified — CI.) + +### CFFIXED_USER_HOME workaround test (decisive evidence) +- Control (no home override): only 1 of 2 ports served CDP; instance B logged `Opening in existing + browser session`; both resolved the same `~/Library/Application Support//dev/CEF`. +- Workaround (`CFFIXED_USER_HOME=/tmp/eb-home-N` per instance): both `:9361/json/version` and + `:9362/json/version` live simultaneously, 2 CEF trees, caches under each redirected home, both WS- + driveable. `CFFIXED_USER_HOME` alone is sufficient. +- Launch-arg port override: **does not work** (see above) → per-worker bundle copy required. +- Caveats: benign `Cannot create profile at path .../partitions/default` + transient + `blink.mojom.Widget` warnings appear in ALL runs (incl. baseline), not caused by the redirect; + bundled resources render fine under a redirected home; startup time unaffected. + +## API gotcha worth recording +`app` is a **named export** (`import { app } from "electrobun/bun"`), not on the default export. `Electrobun.app.on(...)` throws; the default export only carries `.events`. + +--- + +*Spike artifacts (throwaway, gitignored): `spike/electrobun-spike/` — `app/`, `scripts/`, `chromedriver/`, `app-stdout.log`. All processes/ports were cleaned up.* diff --git a/agent-os/specs/20260528-electrobun-service/UPSTREAM_ISSUE_DRAFT.md b/agent-os/specs/20260528-electrobun-service/UPSTREAM_ISSUE_DRAFT.md new file mode 100644 index 000000000..183ba0b7d --- /dev/null +++ b/agent-os/specs/20260528-electrobun-service/UPSTREAM_ISSUE_DRAFT.md @@ -0,0 +1,35 @@ +# Draft upstream issue — Electrobun multi-instance support (NOT yet filed) + +**Status:** drafted, **on hold** (maintainer decision to not post yet — revisit later). +**Target repo:** `blackboardsh/electrobun`. + +## Why +`@wdio/electrobun-service` multiremote/parallel test execution needs to run multiple +concurrent instances of the same app. Today that requires a workaround (per-worker +`.app` clone + `build.json` port edit + distinct `CFFIXED_USER_HOME`). A launch-time +override would make it first-class. See RESEARCH_FINDINGS.md for the mechanism. + +## Existing adjacent issues (searched — none covers this exact need) +- **#445** Make CEF remote debugging opt-in — same `settings.remote_debugging_port` code path (security framing, not a runtime port override). +- **#380** macOS CEF persistent partitions / custom request-context cache paths — the cache-root machinery our isolation depends on. +- **#227** `requestSingleInstanceLock()` equivalent — the implicit single-instance folding is our root cause; that issue asks to *enforce* single-instance (inverse need). +- **#228** Batteries-included E2E tests (`electrobun test`) — overlapping automation goals (their built-in runner). +- (#438 already tracks the `dirname` bundler crash we hit; #424 is the no-TCP RPC transport.) + +## Drafted issue + +**Title:** Feature: opt-in multi-instance support (per-launch remote-debugging port + cache/user-data dir) for parallel automated testing + +**Body:** + +> **Context** — I'm building a WebdriverIO service (`@wdio/electrobun-service`) that drives CEF-rendered Electrobun apps over CDP (Chromedriver attaches via `goog:chromeOptions.debuggerAddress`). Single-instance automation works great on macOS — CEF serves `/json`, targets enumerate per webview, element-driving + deeplinks all work. 🎉 +> +> **Blocker for parallel/multi-worker testing** — running two instances of the same app at once needs hacks, because: +> 1. **Single-instance per cache root** — `settings.root_cache_path` derives from `identifier`+`channel` (`buildAppDataPath(...)`), so a 2nd launch of the same bundle folds into the 1st (`"Opening in existing browser session."`) with no 2nd CEF/debug endpoint. +> 2. **Remote-debugging port is build-time only** — read from the bundle's `Contents/Resources/build.json` `chromiumFlags["remote-debugging-port"]`; `main.js` doesn't forward process argv to CEF, so a `--remote-debugging-port` launch arg is ignored. +> +> **Current workaround (works, heavy)** — per worker: `cp -c` the `.app`, rewrite the copy's `build.json` port, launch with a distinct `CFFIXED_USER_HOME` (redirects the cache root via `CFCopyHomeDirectoryURL`). macOS-only confidence; N bundle copies; relies on undocumented env behavior. +> +> **Request** — a documented way to run concurrent instances of one app for automated testing, ideally launch-time overrides: `ELECTROBUN_REMOTE_DEBUGGING_PORT=` and `ELECTROBUN_USER_DATA_DIR=` (or an "allow multiple instances" mode). Also benefits DevTools/MCP and any external harness (WDIO, Playwright). +> +> **Related:** #445 (remote-debugging opt-in — same code path), #380 (custom CEF cache paths), #227 (single-instance lock — inverse need / root cause), #228 (batteries-included E2E). Happy to test / contribute a PR. diff --git a/docs/architecture.md b/docs/architecture.md index 240e63535..755109a28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,12 +51,14 @@ This document describes the architecture of the WebdriverIO Desktop & Mobile mon | `@wdio/electron-service` | WebdriverIO service for Electron apps | | `@wdio/tauri-service` | WebdriverIO service for Tauri apps | | `@wdio/dioxus-service` | WebdriverIO service for Dioxus desktop apps | +| `@wdio/electrobun-service` | WebdriverIO service for Electrobun desktop apps | ### Bridge/Plugin Packages | Package | Responsibility | |---------|---------------| -| `@wdio/electron-cdp-bridge` | Chrome DevTools Protocol bridge for main process access | +| `@wdio/electron-cdp-bridge` | CDP client for the Electron main-process debugger (single target) | +| `@wdio/electrobun-cdp-bridge` | Multi-target CDP client for Electrobun's CEF webviews (one connection per window) | | `@wdio/tauri-plugin` | Tauri v2 plugin for backend command invocation | | `wdio-dioxus-bridge` | Dioxus bridge crate — IPC channel, mock dispatch, log forwarding, embedded driver wiring | | `wdio-dioxus-embedded-driver` | In-process WebDriver HTTP server for Dioxus | @@ -89,7 +91,7 @@ Runs in the main process (no `browser` access). Responsible for: ### Service (`service.ts`) Runs in the worker process (receives `browser` via `before` hook). Responsible for: -- API injection (`browser.tauri.*`, `browser.electron.*`, `browser.dioxus.*`) +- API injection (`browser.tauri.*`, `browser.electron.*`, `browser.dioxus.*`, `browser.electrobun.*`) - Mock lifecycle management - Log forwarding setup - Plugin initialization diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index eb20dacc0..e542a4768 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -20,15 +20,19 @@ e2e/ │ │ ├── api.spec.ts │ │ ├── logging.spec.ts │ │ └── ... -│ └── dioxus/ # Dioxus E2E tests +│ ├── dioxus/ # Dioxus E2E tests +│ │ ├── api.spec.ts +│ │ ├── mock.spec.ts +│ │ └── ... +│ └── electrobun/ # Electrobun E2E tests (macOS-only; CI runs the `standard` suite) │ ├── api.spec.ts -│ ├── mock.spec.ts │ └── ... ├── lib/ # Shared test utilities ├── wdio.electron.conf.ts # Electron WDIO config ├── wdio.tauri.conf.ts # Tauri WDIO config ├── wdio.tauri-embedded.conf.ts # Tauri embedded WDIO config -└── wdio.dioxus-embedded.conf.ts # Dioxus embedded WDIO config +├── wdio.dioxus-embedded.conf.ts # Dioxus embedded WDIO config +└── wdio.electrobun.conf.ts # Electrobun WDIO config fixtures/e2e-apps/ ├── electron-builder/ # Electron app (builder packaging) @@ -36,7 +40,8 @@ fixtures/e2e-apps/ ├── electron-no-binary/ # Electron app (no binary mode) ├── electron-script/ # Electron app (script mode) ├── tauri/ # Tauri app -└── dioxus/ # Dioxus app +├── dioxus/ # Dioxus app +└── electrobun/ # Electrobun app (CEF renderer) ``` ## Running E2E Tests diff --git a/docs/package-structure.md b/docs/package-structure.md index c47383310..0635895b0 100644 --- a/docs/package-structure.md +++ b/docs/package-structure.md @@ -99,7 +99,9 @@ All npm packages use the `@wdio/` scope: - `@wdio/electron-service` - Electron WDIO service - `@wdio/tauri-service` - Tauri WDIO service - `@wdio/dioxus-service` - Dioxus WDIO service -- `@wdio/electron-cdp-bridge` - Chrome DevTools Protocol bridge +- `@wdio/electrobun-service` - Electrobun WDIO service +- `@wdio/electron-cdp-bridge` - CDP client for the Electron main-process debugger (single target) +- `@wdio/electrobun-cdp-bridge` - Multi-target CDP client for Electrobun's CEF webviews (one connection per window) - `@wdio/native-utils` - Cross-platform utilities - `@wdio/native-types` - Shared TypeScript type definitions - `@wdio/native-spy` - Spy utilities for mocking diff --git a/e2e/package.json b/e2e/package.json index 7b0e241c9..04b809248 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -45,6 +45,9 @@ "test:e2e:dioxus:standalone": "tsx test/dioxus/standalone/api.spec.ts", "test:e2e:dioxus:deeplink": "cross-env TEST_TYPE=deeplink wdio run wdio.dioxus-embedded.conf.ts", "test:e2e:dioxus-external": "wdio run wdio.dioxus.conf.ts", + "test:e2e:electrobun": "wdio run wdio.electrobun.conf.ts", + "test:e2e:electrobun:window": "cross-env TEST_TYPE=window wdio run wdio.electrobun.conf.ts", + "test:e2e:electrobun:deeplink": "cross-env TEST_TYPE=deeplink wdio run wdio.electrobun.conf.ts", "protocol-install:tauri": "../fixtures/e2e-apps/tauri/scripts/protocol-install.sh", "protocol-install:electron-builder": "../fixtures/e2e-apps/electron-builder/scripts/protocol-install.sh", "protocol-install:electron-forge": "../fixtures/e2e-apps/electron-forge/scripts/protocol-install.sh" @@ -52,6 +55,7 @@ "dependencies": { "@wdio/cli": "catalog:default", "@wdio/dioxus-service": "link:../packages/dioxus-service", + "@wdio/electrobun-service": "link:../packages/electrobun-service", "@wdio/electron-service": "link:../packages/electron-service", "@wdio/globals": "catalog:default", "@wdio/local-runner": "catalog:default", diff --git a/e2e/test/electrobun/api.spec.ts b/e2e/test/electrobun/api.spec.ts new file mode 100644 index 000000000..fe371b44b --- /dev/null +++ b/e2e/test/electrobun/api.spec.ts @@ -0,0 +1,72 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +describe('Electrobun API', () => { + it('should execute a basic expression', async () => { + const result = await browser.electrobun.execute('1 + 2 + 3'); + expect(result).toBe(6); + }); + + it('should execute a statement-style script with a return', async () => { + const result = await browser.electrobun.execute('return 42'); + expect(result).toBe(42); + }); + + it('should execute a script with variable declarations', async () => { + const result = await browser.electrobun.execute(` + const x = 10; + const y = 20; + return x + y; + `); + expect(result).toBe(30); + }); + + it('should access the DOM from a string script', async () => { + const result = await browser.electrobun.execute('return document.title'); + expect(typeof result).toBe('string'); + }); + + describe('execute - different script types', () => { + it('should pass the electrobun surface as the first callback arg', async () => { + const result = await browser.electrobun.execute((eb) => ({ hasSurface: typeof eb === 'object' })); + expect(result.hasSurface).toBe(true); + }); + + it('should execute a function with the surface and args (with-args branch)', async () => { + const result = await browser.electrobun.execute((_eb, arg1, arg2) => ({ arg1, arg2 }), 'first', 'second'); + expect(result.arg1).toBe('first'); + expect(result.arg2).toBe('second'); + }); + + it('should execute an async function with args', async () => { + const result = await browser.electrobun.execute(async (_eb, value) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { received: value }; + }, 'async-test'); + expect(result.received).toBe('async-test'); + }); + + it('should read the fixture DOM from a function script', async () => { + // The callback runs in the CEF webview, so `document` is the page DOM. The + // e2e tsconfig has no DOM lib (these specs run via tsx, not tsc), so reach + // it through a minimally-typed globalThis rather than a bare global. + type Doc = { getElementById(id: string): { textContent: string | null } | null }; + const readTitle = () => + browser.electrobun.execute(() => { + const el = (globalThis as unknown as { document: Doc }).document.getElementById('app-title'); + return el ? el.textContent : undefined; + }); + // The webview may still be painting when the bridge attaches, so poll for the + // title rather than reading once (avoids a flaky read-before-render). + let result: string | null | undefined; + await browser.waitUntil( + async () => { + result = await readTitle(); + return typeof result === 'string' && result.includes('Electrobun'); + }, + { timeout: 10_000, timeoutMsg: 'fixture #app-title never rendered' }, + ); + expect(result).toContain('Electrobun'); + }); + }); +}); diff --git a/e2e/test/electrobun/application.spec.ts b/e2e/test/electrobun/application.spec.ts new file mode 100644 index 000000000..7fc3f85af --- /dev/null +++ b/e2e/test/electrobun/application.spec.ts @@ -0,0 +1,42 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +describe('Electrobun application', () => { + it('should launch and render the fixture app title', async () => { + const title = await browser.$('#app-title'); + await expect(title).toExist(); + await expect(title).toHaveText(expect.stringContaining('Electrobun')); + }); + + it('should have a running webview with a URL', async () => { + const url = await browser.getUrl(); + expect(typeof url).toBe('string'); + }); + + it('should start with the counter at zero', async () => { + const counter = await browser.$('#counter'); + await expect(counter).toHaveText('0'); + }); + + it('should increment the counter when the increment button is clicked', async () => { + await browser.$('#reset-button').click(); + await browser.$('#increment-button').click(); + await expect(browser.$('#counter')).toHaveText('1'); + await expect(browser.$('#status')).toHaveText(expect.stringContaining('Incremented')); + }); + + it('should decrement the counter when the decrement button is clicked', async () => { + await browser.$('#reset-button').click(); + await browser.$('#decrement-button').click(); + await expect(browser.$('#counter')).toHaveText('-1'); + await expect(browser.$('#status')).toHaveText(expect.stringContaining('Decremented')); + }); + + it('should reset the counter when the reset button is clicked', async () => { + await browser.$('#increment-button').click(); + await browser.$('#increment-button').click(); + await browser.$('#reset-button').click(); + await expect(browser.$('#counter')).toHaveText('0'); + await expect(browser.$('#status')).toHaveText(expect.stringContaining('reset')); + }); +}); diff --git a/e2e/test/electrobun/deeplink.spec.ts b/e2e/test/electrobun/deeplink.spec.ts new file mode 100644 index 000000000..72ae11849 --- /dev/null +++ b/e2e/test/electrobun/deeplink.spec.ts @@ -0,0 +1,117 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +// Electrobun deeplinks are macOS-only in 0.x: triggerDeeplink rejects on other +// platforms (Windows/Linux URL-scheme registration is not yet available upstream). +// The fixture's Bun backend (src/bun/index.ts) handles `open-url` and pushes the +// URL into the main view's window.__wdioDeeplinks array + the #status element. +// +// ⚠️ NOT RUN IN CI (skipped). `open ` must reach the running instance, but +// electrobun has no single-instance / open-url routing, so with the per-worker +// bundle clone macOS launches a SECOND instance that can't surface into the +// worker's session. Not fixable from the fixture/service. The CI matrix runs only +// `standard` (see ci.yml + the agent-os plan "Framework gaps"). Runnable LOCALLY +// via `TEST_TYPE=deeplink pnpm test:e2e:electrobun`; re-folded into CI once +// electrobun adds a single-instance lock + open-url routing. +const isMacOS = process.platform === 'darwin'; + +// The execute callback runs in the CEF webview, where globalThis is the page +// window — that's where the fixture's open-url handler pushes __wdioDeeplinks. +function readDeeplinks(): Promise { + return browser.electrobun.execute( + () => ((globalThis as unknown as { __wdioDeeplinks?: string[] }).__wdioDeeplinks ?? []) as string[], + ); +} + +async function clearDeeplinks(): Promise { + await browser.electrobun.execute(() => { + (globalThis as unknown as { __wdioDeeplinks?: string[] }).__wdioDeeplinks = []; + }); +} + +async function waitForDeeplink(expectedCount = 1, timeoutMsg = 'App did not receive the deeplink'): Promise { + await browser.waitUntil( + async () => { + const links = await readDeeplinks(); + return links.length >= expectedCount; + }, + { timeout: 30000, timeoutMsg }, + ); +} + +describe('Electrobun Deeplink Testing (browser.electrobun.triggerDeeplink)', () => { + describe('Platform support', () => { + it('should reject triggerDeeplink on non-macOS platforms', async function () { + if (isMacOS) { + this.skip(); + } + await expect(browser.electrobun.triggerDeeplink('wdio-electrobun://simple')).rejects.toThrow(); + }); + }); + + describe('Basic Deeplink Functionality', () => { + beforeEach(async function () { + if (!isMacOS) { + this.skip(); + } + await browser.electrobun.switchWindow('main'); + await clearDeeplinks(); + }); + + it('should trigger a simple deeplink', async () => { + await browser.electrobun.triggerDeeplink('wdio-electrobun://simple'); + await waitForDeeplink(1, 'App did not receive the deeplink within 30 seconds'); + + const deeplinks = await readDeeplinks(); + expect(deeplinks[0]).toMatch(/^wdio-electrobun:\/\/simple\/?$/); + }); + + it('should handle deeplinks with paths', async () => { + await browser.electrobun.triggerDeeplink('wdio-electrobun://open/file/path'); + await waitForDeeplink(1, 'App did not receive the deeplink with path'); + + const deeplinks = await readDeeplinks(); + expect(deeplinks.some((url) => url.includes('open/file/path'))).toBe(true); + }); + + it('should preserve query parameters', async () => { + await browser.electrobun.triggerDeeplink('wdio-electrobun://action?param1=value1¶m2=value2'); + await waitForDeeplink(1, 'App did not receive the deeplink with parameters'); + + const deeplinks = await readDeeplinks(); + const received = deeplinks[0]; + expect(received).toContain('param1=value1'); + expect(received).toContain('param2=value2'); + }); + }); + + describe('Error Handling', () => { + beforeEach(async function () { + if (!isMacOS) { + this.skip(); + } + }); + + it('should reject an invalid URL format', async () => { + await expect(browser.electrobun.triggerDeeplink('not a valid url')).rejects.toThrow(); + }); + + it('should reject the http protocol', async () => { + await expect(browser.electrobun.triggerDeeplink('http://example.com')).rejects.toThrow( + /Invalid deeplink protocol/, + ); + }); + + it('should reject the https protocol', async () => { + await expect(browser.electrobun.triggerDeeplink('https://example.com')).rejects.toThrow( + /Invalid deeplink protocol/, + ); + }); + + it('should reject the file protocol', async () => { + await expect(browser.electrobun.triggerDeeplink('file:///path/to/file')).rejects.toThrow( + /Invalid deeplink protocol/, + ); + }); + }); +}); diff --git a/e2e/test/electrobun/execute-data-types.spec.ts b/e2e/test/electrobun/execute-data-types.spec.ts new file mode 100644 index 000000000..888c843d6 --- /dev/null +++ b/e2e/test/electrobun/execute-data-types.spec.ts @@ -0,0 +1,59 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +describe('Electrobun Execute - Data Types', () => { + it('should return complex nested objects', async () => { + const result = await browser.electrobun.execute(() => ({ + nested: { + array: [1, 2, 3], + object: { key: 'value' }, + null: null, + boolean: true, + number: 42, + string: 'test', + }, + })); + expect(result?.nested.array).toEqual([1, 2, 3]); + expect(result?.nested.object.key).toBe('value'); + expect(result?.nested.null).toBeNull(); + expect(result?.nested.boolean).toBe(true); + expect(result?.nested.number).toBe(42); + expect(result?.nested.string).toBe('test'); + }); + + it('should return arrays correctly', async () => { + const result = await browser.electrobun.execute(() => ['a', 'b', 'c']); + expect(Array.isArray(result)).toBe(true); + expect(result).toHaveLength(3); + expect(result?.[0]).toBe('a'); + }); + + it('should return primitive values', async () => { + expect(await browser.electrobun.execute(() => 'a string')).toBe('a string'); + expect(await browser.electrobun.execute(() => 123)).toBe(123); + expect(await browser.electrobun.execute(() => true)).toBe(true); + expect(await browser.electrobun.execute(() => null)).toBeNull(); + }); + + it('should handle large return values', async () => { + const result = await browser.electrobun.execute(() => { + const large = []; + for (let i = 0; i < 1000; i++) { + large.push({ id: i, value: `item-${i}` }); + } + return large; + }); + expect(result).toHaveLength(1000); + expect(result?.[0]?.id).toBe(0); + expect(result?.[999]?.id).toBe(999); + expect(result?.[999]?.value).toBe('item-999'); + }); + + it('should round-trip arguments through JSON inlining', async () => { + const result = await browser.electrobun.execute((_eb, payload) => payload, { + items: [1, 2, 3], + meta: { ok: true }, + }); + expect(result).toEqual({ items: [1, 2, 3], meta: { ok: true } }); + }); +}); diff --git a/e2e/test/electrobun/execute.spec.ts b/e2e/test/electrobun/execute.spec.ts new file mode 100644 index 000000000..b634631b1 --- /dev/null +++ b/e2e/test/electrobun/execute.spec.ts @@ -0,0 +1,68 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +describe('Electrobun Execute - Advanced Patterns', () => { + it('should pass multiple parameters to an execute function', async () => { + const result = await browser.electrobun.execute((_eb, a, b, c) => a + b + c, 10, 20, 30); + expect(result).toBe(60); + }); + + it('should handle destructured parameters', async () => { + const result = await browser.electrobun.execute((_eb, { name, value }) => `${name}: ${value}`, { + name: 'test', + value: 42, + }); + expect(result).toBe('test: 42'); + }); + + it('should execute an async function returning an object', async () => { + const result = await browser.electrobun.execute(async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + return { ok: true, count: 3 }; + }); + expect(result?.ok).toBe(true); + expect(result?.count).toBe(3); + }); + + it('should handle functions with inner function declarations', async () => { + // String form avoids esbuild __name transpilation reaching the page. + const result = await browser.electrobun.execute(`(() => { + function helper(x) { return x * 2; } + return helper(21); + })()`); + expect(result).toBe(42); + }); + + it('should handle functions with inner arrow functions', async () => { + const result = await browser.electrobun.execute(`(() => { + const helper = (x) => x * 2; + return helper(21); + })()`); + expect(result).toBe(42); + }); + + it('should propagate synchronous errors from an execute function', async () => { + await expect( + browser.electrobun.execute(() => { + throw new Error('Test error from execute'); + }), + ).rejects.toThrow('Test error from execute'); + }); + + it('should propagate promise rejections from an execute function', async () => { + await expect( + browser.electrobun.execute(async () => { + return await Promise.reject(new Error('Async error')); + }), + ).rejects.toThrow('Async error'); + }); + + it('should reject when inlining a non-serialisable argument', async () => { + await expect( + browser.electrobun.execute( + (_eb, fn) => fn, + () => 1, + ), + ).rejects.toThrow(/JSON-serialisable/); + }); +}); diff --git a/e2e/test/electrobun/logging.spec.ts b/e2e/test/electrobun/logging.spec.ts new file mode 100644 index 000000000..81ea086a9 --- /dev/null +++ b/e2e/test/electrobun/logging.spec.ts @@ -0,0 +1,46 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; +import path from 'node:path'; +import url from 'node:url'; +import { getLogDirName, readWdioLogs } from '../../lib/utils.js'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +function getLogDir() { + const testType = (process.env.TEST_TYPE as string) || 'standard'; + const logDirName = getLogDirName(testType, 'electrobun'); + return path.join(__dirname, '..', '..', 'logs', logDirName); +} + +// The service forwards the Electrobun Bun backend's stdout/stderr into the WDIO +// log when `captureBackendLogs` is set (wdio.electrobun.conf.ts). The fixture's +// Bun backend (fixtures/e2e-apps/electrobun/src/bun/index.ts) prints '[e2e]' +// startup lines, which the launcher tags with a '[backend]' prefix. +describe('Electrobun Log Integration', () => { + describe('Backend Log Capture', () => { + it('should capture the Bun backend stdout in the WDIO log', async () => { + await browser.waitUntil( + async () => { + const logs = await readWdioLogs(getLogDir()); + return logs.includes('[backend]'); + }, + { timeout: 10000, timeoutMsg: 'Backend logs not captured' }, + ); + + const logs = await readWdioLogs(getLogDir()); + expect(logs).toMatch(/\[backend\].*\[e2e\]/s); + }); + + it('should capture the backend ready marker', async () => { + const logs = await readWdioLogs(getLogDir()); + expect(logs).toContain('bun backend ready'); + }); + }); + + describe('Log Infrastructure', () => { + it('should have a non-empty log directory', async () => { + const logs = await readWdioLogs(getLogDir()); + expect(logs.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/e2e/test/electrobun/mock.spec.ts b/e2e/test/electrobun/mock.spec.ts new file mode 100644 index 000000000..256f1f151 --- /dev/null +++ b/e2e/test/electrobun/mock.spec.ts @@ -0,0 +1,232 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +// Electrobun mocks wrap a function in the webview global scope (window.); +// there is no enumerable main-process API (unlike Electron). The fixture does not +// expose such a function, so each test installs one via execute() first, then +// mocks it — the in-page analogue of mocking a tauri command / dioxus invoke. +// In the webview globalThis === window, so functions defined here are reachable +// by the inner recorder's window. lookup. +async function defineTarget(name: string): Promise { + await browser.electrobun.execute((_eb, fnName) => { + // Real implementation that the mock replaces in place. + (globalThis as unknown as Record)[fnName as string] = (...args: unknown[]) => ({ + real: true, + args, + }); + }, name); +} + +async function callTarget(name: string, ...args: unknown[]): Promise { + return browser.electrobun.execute( + (_eb, fnName, callArgs) => + (globalThis as unknown as Record unknown>)[fnName as string]( + ...(callArgs as unknown[]), + ), + name, + args, + ); +} + +describe('Electrobun Mocking', () => { + beforeEach(async () => { + await browser.electrobun.restoreAllMocks(); + }); + + describe('browser.electrobun.mock', () => { + it('should mock a webview function', async () => { + await defineTarget('getValue'); + const mockGetValue = await browser.electrobun.mock('getValue'); + + await callTarget('getValue'); + await mockGetValue.update(); + + expect(mockGetValue).toHaveBeenCalledTimes(1); + }); + + it('should record call arguments', async () => { + await defineTarget('writeValue'); + const mockWriteValue = await browser.electrobun.mock('writeValue'); + + await callTarget('writeValue', 'test content'); + await mockWriteValue.update(); + + expect(mockWriteValue).toHaveBeenCalledTimes(1); + expect(mockWriteValue).toHaveBeenCalledWith('test content'); + }); + }); + + describe('mock behaviour setters', () => { + it('should use mockReturnValue', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockReturnValue('mocked value'); + + const result = await callTarget('readValue'); + expect(result).toBe('mocked value'); + }); + + it('should use mockImplementation', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockImplementation(() => 'impl value'); + + const result = await callTarget('readValue'); + expect(result).toBe('impl value'); + }); + + it('should use mockReturnValueOnce in sequence', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockReturnValue('default'); + await mockReadValue.mockReturnValueOnce('first'); + await mockReadValue.mockReturnValueOnce('second'); + + expect(await callTarget('readValue')).toBe('first'); + expect(await callTarget('readValue')).toBe('second'); + expect(await callTarget('readValue')).toBe('default'); + }); + + it('should use mockResolvedValue', async () => { + await defineTarget('fetchData'); + const mockFetchData = await browser.electrobun.mock('fetchData'); + await mockFetchData.mockResolvedValue({ ok: true }); + + const result = await browser.electrobun.execute(async (_eb) => + (globalThis as unknown as Record Promise>).fetchData(), + ); + expect(result).toEqual({ ok: true }); + }); + + it('should use mockRejectedValue', async () => { + await defineTarget('fetchData'); + const mockFetchData = await browser.electrobun.mock('fetchData'); + await mockFetchData.mockRejectedValue(new Error('connection failed')); + + const message = await browser.electrobun.execute(async (_eb) => { + try { + await (globalThis as unknown as Record Promise>).fetchData(); + return 'no error'; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + }); + expect(message).toBe('connection failed'); + }); + }); + + describe('browser.electrobun.clearAllMocks', () => { + it('should clear call history without removing the mock', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockReturnValue('still mocked'); + + await callTarget('readValue'); + await mockReadValue.update(); + + await browser.electrobun.clearAllMocks(); + + expect(mockReadValue.mock.calls).toStrictEqual([]); + expect(mockReadValue.mock.results).toStrictEqual([]); + + // Implementation survives a clear. + expect(await callTarget('readValue')).toBe('still mocked'); + }); + }); + + describe('browser.electrobun.resetAllMocks', () => { + it('should clear history and remove implementations', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockReturnValue('mocked'); + + await browser.electrobun.resetAllMocks(); + + expect(mockReadValue.mock.calls).toStrictEqual([]); + const result = await callTarget('readValue'); + expect(result).toBeUndefined(); + }); + }); + + describe('browser.electrobun.restoreAllMocks', () => { + it('should restore the original implementation', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockReturnValue('mocked'); + + await browser.electrobun.restoreAllMocks(); + + const result = (await callTarget('readValue')) as { real?: boolean }; + expect(result?.real).toBe(true); + }); + }); + + describe('browser.electrobun.isMockFunction', () => { + it('should return true for a mocked target', async () => { + await defineTarget('readValue'); + void (await browser.electrobun.mock('readValue')); + + expect(await browser.electrobun.isMockFunction('readValue')).toBe(true); + }); + + it('should return false for a non-mocked target', async () => { + expect(await browser.electrobun.isMockFunction('not_mocked_fn')).toBe(false); + }); + }); + + describe('mock object functionality', () => { + it('should set and get the mock name', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + + expect(mockReadValue.getMockName()).toBe('electrobun.readValue'); + + mockReadValue.mockName('my mock'); + expect(mockReadValue.getMockName()).toBe('my mock'); + }); + + it('should clear an individual mock', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockReturnValue('mocked'); + + await callTarget('readValue'); + await callTarget('readValue'); + + await mockReadValue.update(); + await mockReadValue.mockClear(); + + expect(mockReadValue.mock.calls).toStrictEqual([]); + expect(mockReadValue.mock.results).toStrictEqual([]); + }); + + it('should expose recorded results after update', async () => { + await defineTarget('readValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + await mockReadValue.mockImplementation(() => 'result'); + + await callTarget('readValue'); + await mockReadValue.update(); + + expect(mockReadValue.mock.results).toStrictEqual([{ type: 'return', value: 'result' }]); + }); + + it('should record invocation order across mocks', async () => { + await defineTarget('readValue'); + await defineTarget('getValue'); + const mockReadValue = await browser.electrobun.mock('readValue'); + const mockGetValue = await browser.electrobun.mock('getValue'); + + await callTarget('readValue'); + await callTarget('getValue'); + await callTarget('readValue'); + + await mockReadValue.update(); + await mockGetValue.update(); + + const first = mockReadValue.mock.invocationCallOrder[0]; + expect(mockReadValue.mock.invocationCallOrder).toStrictEqual([first, first + 2]); + expect(mockGetValue.mock.invocationCallOrder).toStrictEqual([first + 1]); + }); + }); +}); diff --git a/e2e/test/electrobun/window.spec.ts b/e2e/test/electrobun/window.spec.ts new file mode 100644 index 000000000..ceefba9b8 --- /dev/null +++ b/e2e/test/electrobun/window.spec.ts @@ -0,0 +1,89 @@ +import { browser, expect } from '@wdio/globals'; +import '@wdio/native-types'; + +// The fixture opens two CEF windows (src/bun/index.ts): the main counter view +// (mainview) and a second view (secondview). The CDP bridge labels content +// targets in registration order — the first becomes 'main', the next 'window-1' +// (see @wdio/electrobun-cdp-bridge TargetRegistry). +// +// ⚠️ NOT RUN IN CI (skipped). Multi-window exercises an upstream CEF per-instance +// profile-isolation gap: BrowserWindow forces a `persist:default` partition the +// chrome-runtime can't create as a non-global profile, so secondview falls back to +// a racy global context and isn't reliably enumerable as 'window-1'. Not fixable +// from the fixture/service. The CI matrix runs only `standard` (see ci.yml + the +// agent-os plan "Framework gaps"). Runnable LOCALLY via +// `TEST_TYPE=window pnpm test:e2e:electrobun`; re-folded into CI once electrobun +// ships per-window partitions / an ephemeral-per-webview fallback. + +describe('Electrobun Multi-Window Support', () => { + beforeEach(async () => { + // Reset to the main window before each test so a prior switch doesn't leak. + try { + await browser.electrobun.switchWindow('main'); + } catch { + // If 'main' is not yet enumerated, continue — listWindows tests cover that. + } + }); + + describe('listWindows()', () => { + it('should list all available windows', async () => { + const windows = await browser.electrobun.listWindows(); + expect(Array.isArray(windows)).toBe(true); + expect(windows.length).toBeGreaterThanOrEqual(2); + expect(windows).toContain('main'); + }); + + it('should label the second window in registration order', async () => { + const windows = await browser.electrobun.listWindows(); + expect(windows).toContain('window-1'); + }); + }); + + describe('switchWindow()', () => { + it('should switch to the main window', async () => { + await browser.electrobun.switchWindow('main'); + const title = await browser.$('#app-title'); + await expect(title).toExist(); + }); + + it('should switch to the second window', async () => { + await browser.electrobun.switchWindow('window-1'); + const marker = await browser.$('#second-marker'); + await expect(marker).toExist(); + }); + + it('should be able to switch back to main after switching away', async () => { + await browser.electrobun.switchWindow('window-1'); + await expect(browser.$('#second-title')).toExist(); + + await browser.electrobun.switchWindow('main'); + await expect(browser.$('#app-title')).toExist(); + }); + + it('should throw for a non-existent window', async () => { + await expect(browser.electrobun.switchWindow('nonexistent-window-12345')).rejects.toThrow(); + }); + }); + + describe('per-window execute', () => { + // The execute callback runs in the active CEF webview; reach the page DOM via + // a minimally-typed globalThis (the e2e tsconfig has no DOM lib). + type Doc = { getElementById(id: string): { id: string } | null }; + + it('should evaluate against the active window after switching', async () => { + await browser.electrobun.switchWindow('window-1'); + const id = await browser.electrobun.execute(() => { + const el = (globalThis as unknown as { document: Doc }).document.getElementById('second-title'); + return el ? el.id : undefined; + }); + expect(id).toBe('second-title'); + + await browser.electrobun.switchWindow('main'); + const mainId = await browser.electrobun.execute(() => { + const el = (globalThis as unknown as { document: Doc }).document.getElementById('app-title'); + return el ? el.id : undefined; + }); + expect(mainId).toBe('app-title'); + }); + }); +}); diff --git a/e2e/wdio.electrobun.conf.ts b/e2e/wdio.electrobun.conf.ts new file mode 100644 index 000000000..a1b17e310 --- /dev/null +++ b/e2e/wdio.electrobun.conf.ts @@ -0,0 +1,188 @@ +import { existsSync, globSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ElectrobunCapabilities, ElectrobunServiceOptions } from '@wdio/native-types'; + +import { getLogDirName } from './lib/utils.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const appDir = join(__dirname, '..', 'fixtures', 'e2e-apps', 'electrobun'); + +/** + * Locate the built Electrobun `.app` bundle. + * + * Electrobun is CDP-attach (like Electron, unlike the Wry-based Tauri/Dioxus): the + * launcher spawns the binary and the worker attaches over CDP, so we hand the + * service the bundle path via `appBinaryPath` exactly as the Electron config hands + * it a resolved binary. + * + * Electrobun writes the bundle under `build//.app` and the + * environment subdir (dev/canary/stable) is not fixed across the beta toolchain, + * so we glob for the first `.app` rather than hardcoding a subpath. CI can pin an + * exact bundle via `ELECTROBUN_APP_PATH` (set after the build step) to avoid any + * ambiguity. macOS is the only validated platform (see the service's + * RESEARCH_FINDINGS); the Windows/Linux bundle layout is unverified. + */ +function resolveElectrobunAppPath(dir: string): string { + const override = process.env.ELECTROBUN_APP_PATH; + if (override) { + return override; + } + + const buildDir = join(dir, 'build'); + if (!existsSync(buildDir)) { + throw new Error( + `Electrobun build directory not found: ${buildDir}. ` + + 'Run `electrobun build` in fixtures/e2e-apps/electrobun first ' + + '(or set ELECTROBUN_APP_PATH to the built bundle).', + ); + } + + // Newest-built wins when the toolchain emits more than one environment dir + // (dev / canary / stable), rather than a lexicographic pick. Stat each candidate + // once up front — not inside the comparator, which would re-stat O(n log n) times — + // and tolerate a path that races away between glob and stat. + const newest = (paths: string[]): string => { + const mtimeOf = (p: string): number => { + try { + return statSync(p).mtimeMs; + } catch { + return 0; + } + }; + return paths.map((path) => ({ path, mtime: mtimeOf(path) })).sort((a, b) => b.mtime - a.mtime)[0].path; + }; + + if (process.platform === 'darwin') { + // macOS: a `.app` bundle (the binary lives in Contents/MacOS). `**/*.app` also + // matches helper bundles nested INSIDE the main app + // (`…/Contents/Frameworks/bun Helper (GPU).app`) — those have no CEF framework, + // so keep only top-level `.app`s or we'd resolve appBinaryPath to a helper. + const bundles = globSync(join(buildDir, '**', '*.app')).filter((p) => !/\.app[\\/]/.test(p)); + if (bundles.length > 0) { + return newest(bundles); + } + throw new Error( + `No Electrobun .app bundle found under ${buildDir}. ` + + 'Set ELECTROBUN_APP_PATH to the built app bundle (or its inner binary).', + ); + } + + // Linux/Windows: electrobun emits `build///bin/launcher[.exe]` with a + // sibling `Resources/build.json` (no `.app`), so glob for the launcher binary. The + // helper executables are named `bun Helper (…)`, never `launcher`, so this can't + // match a helper. + const launcherName = process.platform === 'win32' ? 'launcher.exe' : 'launcher'; + const launchers = globSync(join(buildDir, '**', 'bin', launcherName)); + if (launchers.length > 0) { + return newest(launchers); + } + throw new Error( + `No Electrobun launcher (bin/${launcherName}) found under ${buildDir}. ` + + 'Set ELECTROBUN_APP_PATH to the built launcher binary.', + ); +} + +const appBinaryPath = resolveElectrobunAppPath(appDir); +if (!existsSync(appBinaryPath)) { + throw new Error(`Electrobun app path does not exist: ${appBinaryPath}. Make sure the app is built.`); +} + +const testType = (process.env.TEST_TYPE as string) || 'standard'; + +let specs: string[] = []; +let exclude: string[] = []; +// Pinned to 1: multiremote is blocked upstream (CEF can't isolate ≥2 instances — +// see the agent-os plan "Framework gaps"). Electrobun is single-instance for now. +let maxInstances = 1; + +// CI runs ONLY `standard` (see ci.yml — the macOS matrix is `['standard']`). The +// `window` (multi-window) and `deeplink` cases are kept for LOCAL runs +// (`TEST_TYPE=window|deeplink`) but are not wired into CI: both hit upstream CEF +// gaps (per-window partition / no open-url routing) documented in their spec files. +switch (testType) { + case 'window': + specs = ['./test/electrobun/window.spec.ts']; + break; + case 'deeplink': + // Deeplink tests dispatch the OS protocol handler and must not race parallel apps. + specs = ['./test/electrobun/deeplink.spec.ts']; + maxInstances = 1; + break; + default: + specs = ['./test/electrobun/*.spec.ts']; + // window: enumerates the two CEF page targets; runs in its own pass. + // deeplink: macOS-only, single-instance, dispatches the OS protocol handler. + exclude = ['./test/electrobun/window.spec.ts', './test/electrobun/deeplink.spec.ts']; + break; +} + +type ElectrobunCapability = ElectrobunCapabilities & { + 'wdio:electrobunServiceOptions': ElectrobunServiceOptions; +}; + +const electrobunServiceOptions: ElectrobunServiceOptions = { + appBinaryPath, + appArgs: ['foo', 'bar=baz'], + // Forward the Bun backend's stdout/stderr into the WDIO log for the logging spec. + captureBackendLogs: true, + backendLogLevel: 'info', +}; + +const capabilities: ElectrobunCapability[] = [ + { + // CDP-attach: the launcher rewrites this 'electrobun' → 'chrome' and sets + // goog:chromeOptions.debuggerAddress onto the capability in onWorkerStart. + browserName: 'electrobun', + // Electrobun 1.18.1 bundles CEF on Chromium 147 (147.0.7727.118); pin the + // driver to that major so WDIO doesn't fetch the latest (148+), which refuses + // to attach with "only supports Chrome version N". Matching the major is what + // matters (spike RESEARCH_FINDINGS §2). Bump alongside the Electrobun/CEF pin. + browserVersion: '147', + 'wdio:electrobunServiceOptions': electrobunServiceOptions, + }, +]; + +const logDirName = getLogDirName(testType, 'electrobun'); +const logDir = join(__dirname, 'logs', logDirName); + +export const config = { + runner: 'local', + specs, + exclude, + maxInstances, + capabilities, + logLevel: 'info', + bail: 0, + // Residual upstream CEF race on the macOS `standard` suite: the 2-window fixture + // (needed because a single CEF window doesn't reliably expose a `/json` target) trips CEF's + // failed-profile → global-context fallback, which surfaces as either an unpainted + // `#app-title` or a "Timeout of new browser info response" on the second frame — for + // that app instance's whole lifetime (see the plan "Framework gaps"). mochaOpts.retries + // can't escape it (same instance); a spec-FILE retry re-spawns a fresh CEF instance. + // Bumped to 3 (4 attempts/spec) — at 2 the gate occasionally exhausted retries on a + // run with an elevated CEF-timeout rate. Drop back once the upstream fix lands. + specFileRetries: 3, + specFileRetriesDeferred: false, + baseUrl: '', + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + // Electrobun is CDP-attach: the app binary is spawned from the worker process + // (no separate driver in the launcher needing a display), so the Electron + // headless approach applies — let @wdio/xvfb auto-manage Xvfb on Linux rather + // than wrapping the whole command with xvfb-run (the Wry tauri/dioxus path). + autoXvfb: true, + services: ['electrobun'], + framework: 'mocha', + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: 60000, + retries: 2, + }, + outputDir: logDir, +}; diff --git a/fixtures/e2e-apps/electrobun/.gitignore b/fixtures/e2e-apps/electrobun/.gitignore new file mode 100644 index 000000000..5c81a3773 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +build/ +dist/ +artifacts/ +*.tsbuildinfo diff --git a/fixtures/e2e-apps/electrobun/electrobun.config.ts b/fixtures/e2e-apps/electrobun/electrobun.config.ts new file mode 100644 index 000000000..20f3e2b69 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/electrobun.config.ts @@ -0,0 +1,47 @@ +import type { ElectrobunConfig } from 'electrobun'; + +// CEF renderer is mandatory on every OS: it is the only renderer that exposes a +// CDP endpoint, which is how @wdio/electrobun-service attaches. The service pins +// the remote-debugging port into the built bundle's build.json at launch, so no +// port is hardcoded here. +const bundleCEF = true; + +export default { + app: { + name: 'WDIO Electrobun E2E', + identifier: 'com.wdio.electrobun.e2e', + version: '0.1.0', + urlSchemes: ['wdio-electrobun'], + }, + build: { + bun: { + entrypoint: 'src/bun/index.ts', + }, + // Two views drive the multi-window surface (switchWindow / listWindows): the + // main counter window plus a second window with its own CEF page target. + views: { + mainview: { + entrypoint: 'src/mainview/index.ts', + }, + secondview: { + entrypoint: 'src/secondview/index.ts', + }, + }, + copy: { + 'src/mainview/index.html': 'views/mainview/index.html', + 'src/secondview/index.html': 'views/secondview/index.html', + }, + mac: { + bundleCEF, + defaultRenderer: 'cef', + }, + win: { + bundleCEF, + defaultRenderer: 'cef', + }, + linux: { + bundleCEF, + defaultRenderer: 'cef', + }, + }, +} satisfies ElectrobunConfig; diff --git a/fixtures/e2e-apps/electrobun/package.json b/fixtures/e2e-apps/electrobun/package.json new file mode 100644 index 000000000..df1c01583 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/package.json @@ -0,0 +1,18 @@ +{ + "name": "electrobun-e2e-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "electrobun build", + "dev": "electrobun dev", + "start": "electrobun run" + }, + "dependencies": { + "electrobun": "1.18.1" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "typescript": "5.9.3" + } +} diff --git a/fixtures/e2e-apps/electrobun/src/bun/index.ts b/fixtures/e2e-apps/electrobun/src/bun/index.ts new file mode 100644 index 000000000..4d8e53618 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/src/bun/index.ts @@ -0,0 +1,54 @@ +import { app, BrowserWindow } from 'electrobun/bun'; + +// Bun (main-process) backend for the Electrobun E2E fixture. Opens TWO CEF windows, +// but STAGGERED: the second only after the main view's DOM is ready. +// +// Two windows are needed so the CDP bridge can enumerate a 'window-1' target — and with +// a single CEF window the chrome-runtime (after the forced `persist:default` partition +// falls back to the shared global context — an upstream gap, see the agent-os plan +// "Framework gaps") doesn't reliably expose a `/json` page target. But opening both CONCURRENTLY makes +// that global-context fallback race: a browser can spawn a separate top-level window +// instead of embedding via SetAsChild, leaving mainview's DOM unpainted +// ("#app-title never rendered" → flaky 4–6/6). Staggering lets mainview embed + paint +// cleanly first, then opens the second view. The bridge labels content targets in +// registration order: first = 'main' (mainview), next = 'window-1' (secondview). + +const mainWindow = new BrowserWindow({ + title: 'WDIO Electrobun E2E — Main', + url: 'views://mainview/index.html', + renderer: 'cef', + frame: { x: 100, y: 100, width: 760, height: 640 }, +}); +console.log('[e2e] opened main window', { main: mainWindow.id }); + +let secondOpened = false; +mainWindow.webview.on('dom-ready', () => { + if (secondOpened) { + return; + } + secondOpened = true; + const secondWindow = new BrowserWindow({ + title: 'WDIO Electrobun E2E — Second', + url: 'views://secondview/index.html', + renderer: 'cef', + frame: { x: 900, y: 150, width: 520, height: 440 }, + }); + console.log('[e2e] opened second window', { second: secondWindow.id }); +}); + +// Deeplink handler — `open wdio-electrobun://` (macOS), routed via the +// urlSchemes entry in electrobun.config.ts. Surface the URL into the main view +// so a WebDriver test can read window.__wdioDeeplinks without a CDP side-channel. +app.on('open-url', (payload: unknown) => { + const url = (payload as { url?: string })?.url ?? ''; + console.log('[e2e][open-url] received:', url); + const js = `(() => { + window.__wdioDeeplinks = window.__wdioDeeplinks || []; + window.__wdioDeeplinks.push(${JSON.stringify(url)}); + var statusEl = document.getElementById('status'); + if (statusEl) { statusEl.textContent = 'Deeplink: ' + ${JSON.stringify(url)}; } + })();`; + mainWindow.webview.executeJavascript(js); +}); + +console.log('[e2e] bun backend ready'); diff --git a/fixtures/e2e-apps/electrobun/src/mainview/index.html b/fixtures/e2e-apps/electrobun/src/mainview/index.html new file mode 100644 index 000000000..00b3958f4 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/src/mainview/index.html @@ -0,0 +1,102 @@ + + + + + + Electrobun E2E Test App + + + +
+

🚀 Electrobun Basic App

+ +
+
0
+ + + +
+ +
+

This is a basic Electrobun application for WebDriverIO testing.

+
Ready for testing
+
+
+ + + + diff --git a/fixtures/e2e-apps/electrobun/src/mainview/index.ts b/fixtures/e2e-apps/electrobun/src/mainview/index.ts new file mode 100644 index 000000000..24218c89c --- /dev/null +++ b/fixtures/e2e-apps/electrobun/src/mainview/index.ts @@ -0,0 +1,31 @@ +export {}; + +const counterEl = document.getElementById('counter') as HTMLSpanElement; +const statusEl = document.getElementById('status') as HTMLDivElement; +const incrementButton = document.getElementById('increment-button') as HTMLButtonElement; +const decrementButton = document.getElementById('decrement-button') as HTMLButtonElement; +const resetButton = document.getElementById('reset-button') as HTMLButtonElement; + +let count = 0; + +function render(message: string): void { + counterEl.textContent = String(count); + statusEl.textContent = message; +} + +incrementButton.addEventListener('click', () => { + count += 1; + render(`Incremented to ${count}`); +}); + +decrementButton.addEventListener('click', () => { + count -= 1; + render(`Decremented to ${count}`); +}); + +resetButton.addEventListener('click', () => { + count = 0; + render('Counter reset'); +}); + +render('Application loaded successfully'); diff --git a/fixtures/e2e-apps/electrobun/src/secondview/index.html b/fixtures/e2e-apps/electrobun/src/secondview/index.html new file mode 100644 index 000000000..eaa9666b5 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/src/secondview/index.html @@ -0,0 +1,68 @@ + + + + + + Electrobun E2E Second Window + + + +
+

🪟 Second Window

+ +
+

This is the second CEF page target, used to exercise switchWindow / listWindows.

+
Ready for testing
+
+
+ + + + diff --git a/fixtures/e2e-apps/electrobun/src/secondview/index.ts b/fixtures/e2e-apps/electrobun/src/secondview/index.ts new file mode 100644 index 000000000..fcecc0c60 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/src/secondview/index.ts @@ -0,0 +1,4 @@ +export {}; + +const statusEl = document.getElementById('status') as HTMLDivElement; +statusEl.textContent = 'Second window loaded'; diff --git a/fixtures/e2e-apps/electrobun/tsconfig.json b/fixtures/e2e-apps/electrobun/tsconfig.json new file mode 100644 index 000000000..b4341d195 --- /dev/null +++ b/fixtures/e2e-apps/electrobun/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable" + ], + "types": [ + "bun" + ], + "noEmit": true + }, + "include": [ + "src", + "electrobun.config.ts" + ] +} diff --git a/fixtures/package-tests/electrobun-app/.gitignore b/fixtures/package-tests/electrobun-app/.gitignore new file mode 100644 index 000000000..5c81a3773 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +build/ +dist/ +artifacts/ +*.tsbuildinfo diff --git a/fixtures/package-tests/electrobun-app/electrobun.config.ts b/fixtures/package-tests/electrobun-app/electrobun.config.ts new file mode 100644 index 000000000..e8b7a05ba --- /dev/null +++ b/fixtures/package-tests/electrobun-app/electrobun.config.ts @@ -0,0 +1,39 @@ +import type { ElectrobunConfig } from 'electrobun'; + +// Reduced install-smoke fixture: a single CEF window. CEF is still enabled so the +// bundle matches what @wdio/electrobun-service attaches to over CDP. +const bundleCEF = true; + +export default { + app: { + name: 'WDIO Electrobun App', + identifier: 'com.wdio.electrobun.app', + version: '0.1.0', + urlSchemes: ['wdio-electrobun-app'], + }, + build: { + bun: { + entrypoint: 'src/bun/index.ts', + }, + views: { + mainview: { + entrypoint: 'src/mainview/index.ts', + }, + }, + copy: { + 'src/mainview/index.html': 'views/mainview/index.html', + }, + mac: { + bundleCEF, + defaultRenderer: 'cef', + }, + win: { + bundleCEF, + defaultRenderer: 'cef', + }, + linux: { + bundleCEF, + defaultRenderer: 'cef', + }, + }, +} satisfies ElectrobunConfig; diff --git a/fixtures/package-tests/electrobun-app/package.json b/fixtures/package-tests/electrobun-app/package.json new file mode 100644 index 000000000..ea0689c68 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/package.json @@ -0,0 +1,25 @@ +{ + "name": "electrobun-app-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "electrobun build", + "test": "wdio run wdio.conf.ts" + }, + "dependencies": { + "electrobun": "1.18.1" + }, + "devDependencies": { + "@types/bun": "1.3.14", + "@types/mocha": "10.0.10", + "@wdio/cli": "9.27.1", + "@wdio/electrobun-service": "workspace:*", + "@wdio/globals": "9.27.1", + "@wdio/local-runner": "9.27.1", + "@wdio/mocha-framework": "9.27.1", + "@wdio/native-types": "workspace:*", + "@wdio/spec-reporter": "9.27.1", + "typescript": "5.9.3" + } +} diff --git a/fixtures/package-tests/electrobun-app/src/bun/index.ts b/fixtures/package-tests/electrobun-app/src/bun/index.ts new file mode 100644 index 000000000..1354f8277 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/src/bun/index.ts @@ -0,0 +1,32 @@ +import { BrowserWindow } from 'electrobun/bun'; + +// Package-install smoke fixture. Opens TWO CEF windows, STAGGERED — the same workaround +// the e2e fixture uses: a single CEF window doesn't reliably expose a `/json` page target once CEF's +// forced `persist:default` partition falls back to the shared global context (an upstream +// gap; see @wdio/electrobun-service "Framework gaps"), so the bridge/Chromedriver would +// have nothing to attach to. Opening a second window makes a content target appear; doing +// it only after the first view's DOM is ready avoids the global-context creation race that +// otherwise leaves the main view unpainted. Both load the same mainview — the smoke only +// reads the main target. +const mainWindow = new BrowserWindow({ + title: 'WDIO Electrobun App', + url: 'views://mainview/index.html', + renderer: 'cef', + frame: { x: 100, y: 100, width: 640, height: 480 }, +}); +console.log('[package-test] opened main window', mainWindow.id); + +let secondOpened = false; +mainWindow.webview.on('dom-ready', () => { + if (secondOpened) { + return; + } + secondOpened = true; + const secondWindow = new BrowserWindow({ + title: 'WDIO Electrobun App — 2', + url: 'views://mainview/index.html', + renderer: 'cef', + frame: { x: 780, y: 150, width: 480, height: 360 }, + }); + console.log('[package-test] opened second window', secondWindow.id); +}); diff --git a/fixtures/package-tests/electrobun-app/src/mainview/index.html b/fixtures/package-tests/electrobun-app/src/mainview/index.html new file mode 100644 index 000000000..ae4758cea --- /dev/null +++ b/fixtures/package-tests/electrobun-app/src/mainview/index.html @@ -0,0 +1,68 @@ + + + + + + Electrobun Package Test App + + + +
+

🚀 Electrobun App

+ +
+

Electrobun package-install smoke fixture for WebDriverIO testing.

+
Ready for testing
+
+
+ + + + diff --git a/fixtures/package-tests/electrobun-app/src/mainview/index.ts b/fixtures/package-tests/electrobun-app/src/mainview/index.ts new file mode 100644 index 000000000..1ce9f78e6 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/src/mainview/index.ts @@ -0,0 +1,4 @@ +export {}; + +const statusEl = document.getElementById('status') as HTMLDivElement; +statusEl.textContent = 'Application loaded successfully'; diff --git a/fixtures/package-tests/electrobun-app/test/smoke.spec.ts b/fixtures/package-tests/electrobun-app/test/smoke.spec.ts new file mode 100644 index 000000000..e42f40090 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/test/smoke.spec.ts @@ -0,0 +1,52 @@ +import { browser, expect } from '@wdio/globals'; + +// The webview callbacks run in the CEF page context; the e2e tsconfig has no DOM lib, so reach +// `document` through a minimally-typed globalThis. +type Doc = { getElementById(id: string): { textContent: string | null } | null }; + +// Package-install smoke test: confirms the *installed* @wdio/electrobun-service tarball can +// launch the CEF app, attach over CDP, and drive the webview. Reduced surface (#app-title + +// #status) — the full feature matrix lives in the e2e suite. +describe('@wdio/electrobun-service package install', () => { + it('should install the browser.electrobun API surface', async () => { + expect(typeof browser.electrobun.execute).toBe('function'); + }); + + it('should launch the CEF app and render the title', async () => { + const readTitle = () => + browser.electrobun.execute(() => { + const el = (globalThis as unknown as { document: Doc }).document.getElementById('app-title'); + return el ? el.textContent : undefined; + }); + // The webview may still be painting when the bridge attaches — poll rather than read once. + let title: string | null | undefined; + await browser.waitUntil( + async () => { + title = await readTitle(); + return typeof title === 'string' && title.includes('Electrobun'); + }, + { timeout: 10_000, timeoutMsg: 'fixture #app-title never rendered' }, + ); + expect(title).toContain('Electrobun'); + }); + + it('should reflect the view script through the status element', async () => { + // The mainview script overwrites #status to "Application loaded successfully" on load, + // so reading it confirms the view's JS ran (not just the static HTML). Poll in case the + // script hasn't run yet when the bridge first attaches. + const readStatus = () => + browser.electrobun.execute(() => { + const el = (globalThis as unknown as { document: Doc }).document.getElementById('status'); + return el ? el.textContent : undefined; + }); + let status: string | null | undefined; + await browser.waitUntil( + async () => { + status = await readStatus(); + return typeof status === 'string' && status.includes('loaded successfully'); + }, + { timeout: 10_000, timeoutMsg: '#status was not updated by the view script' }, + ); + expect(status).toContain('loaded successfully'); + }); +}); diff --git a/fixtures/package-tests/electrobun-app/tsconfig.json b/fixtures/package-tests/electrobun-app/tsconfig.json new file mode 100644 index 000000000..b4341d195 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "lib": [ + "ESNext", + "DOM", + "DOM.Iterable" + ], + "types": [ + "bun" + ], + "noEmit": true + }, + "include": [ + "src", + "electrobun.config.ts" + ] +} diff --git a/fixtures/package-tests/electrobun-app/tsconfig.wdio.json b/fixtures/package-tests/electrobun-app/tsconfig.wdio.json new file mode 100644 index 000000000..4f79ada84 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/tsconfig.wdio.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": [ + "node", + "@wdio/globals/types", + "mocha" + ] + }, + "include": [ + "test/**/*.ts", + "wdio.conf.ts" + ], + "exclude": [ + "node_modules", + "build", + "src" + ] +} diff --git a/fixtures/package-tests/electrobun-app/wdio.conf.ts b/fixtures/package-tests/electrobun-app/wdio.conf.ts new file mode 100644 index 000000000..cc81733b9 --- /dev/null +++ b/fixtures/package-tests/electrobun-app/wdio.conf.ts @@ -0,0 +1,102 @@ +import { existsSync, globSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ElectrobunCapabilities, ElectrobunServiceOptions } from '@wdio/native-types'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +/** + * Resolve the built Electrobun `.app` bundle for the package-install smoke test. + * + * macOS-only: `@wdio/electrobun-service` is macOS-only in 0.x (Linux/Windows CEF is + * upstream-blocked), and the package-test CI job runs on macOS only. Electrobun writes + * the bundle under `build//.app`; the environment subdir isn't fixed + * across the beta toolchain, so glob for the newest top-level `.app` (helper bundles nested + * inside `…/Contents/…` are filtered out). `ELECTROBUN_APP_PATH` overrides for local runs. + */ +function resolveAppBinaryPath(): string { + const override = process.env.ELECTROBUN_APP_PATH; + if (override) { + return override; + } + const buildDir = join(__dirname, 'build'); + if (!existsSync(buildDir)) { + throw new Error(`Electrobun build directory not found: ${buildDir}. Run \`pnpm build\` in this fixture first.`); + } + const bundles = globSync(join(buildDir, '**', '*.app')).filter((p) => !/\.app[\\/]/.test(p)); + if (bundles.length === 0) { + throw new Error(`No Electrobun .app bundle found under ${buildDir}. Set ELECTROBUN_APP_PATH to the built bundle.`); + } + const newestBy = (paths: string[]) => { + const mtime = (p: string) => { + try { + return statSync(p).mtimeMs; + } catch { + return 0; + } + }; + return paths.map((path) => ({ path, mtime: mtime(path) })).sort((a, b) => b.mtime - a.mtime)[0].path; + }; + return newestBy(bundles); +} + +const appBinaryPath = resolveAppBinaryPath(); + +const electrobunServiceOptions: ElectrobunServiceOptions = { + appBinaryPath, + captureBackendLogs: true, + backendLogLevel: 'info', +}; + +type ElectrobunCapability = ElectrobunCapabilities & { + // Standard W3C capability not on the (intentionally minimal) ElectrobunCapabilities + // interface — pins Chromedriver to CEF's Chromium major. + browserVersion?: string; + 'wdio:electrobunServiceOptions': ElectrobunServiceOptions; +}; + +const capabilities: ElectrobunCapability[] = [ + { + // Idiomatic, like the sibling services (browserName: 'tauri'/'dioxus'): the launcher + // rewrites 'electrobun' → 'chrome' + sets debuggerAddress in onWorkerStart. Pin the + // driver to CEF's Chromium major (147) so WDIO doesn't fetch a newer Chromedriver that + // refuses to attach. Bump alongside the Electrobun/CEF pin. + browserName: 'electrobun', + browserVersion: '147', + 'wdio:electrobunServiceOptions': electrobunServiceOptions, + }, +]; + +export const config = { + runner: 'local', + specs: ['./test/**/*.spec.ts'], + exclude: [], + // Single-instance: multiremote is blocked upstream (CEF can't isolate ≥2 instances). + maxInstances: 1, + capabilities, + logLevel: 'info', + bail: 0, + baseUrl: '', + waitforTimeout: 10000, + connectionRetryTimeout: 120000, + connectionRetryCount: 3, + // Residual upstream CEF race (same as the e2e conf): an app instance can occasionally + // come up with the main view unpainted (failed-profile → global-context fallback). + // mochaOpts.retries can't escape it (same instance); a spec-FILE retry re-spawns a fresh + // CEF instance. + specFileRetries: 2, + specFileRetriesDeferred: false, + autoXvfb: false, + outputDir: join(__dirname, 'logs'), + services: ['electrobun'], + framework: 'mocha', + reporters: ['spec'], + mochaOpts: { + ui: 'bdd', + timeout: 60000, + retries: 2, + }, + tsConfigPath: join(__dirname, 'tsconfig.wdio.json'), +}; diff --git a/package.json b/package.json index dd2b9aa93..d909c448d 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "test:package:tauri:browser": "pnpx cross-env DEBUG=@wdio/tauri-service tsx ./scripts/test-package.ts --service=tauri --mode=browser", "test:package:dioxus": "pnpx tsx ./scripts/test-package.ts --service=dioxus", "test:package:dioxus:browser": "pnpx tsx ./scripts/test-package.ts --service=dioxus --mode=browser", + "test:package:electrobun": "pnpx tsx ./scripts/test-package.ts --service=electrobun", "test:unit": "turbo run test:unit", "typecheck": "turbo run typecheck", "update:dependencies": "pnpm catalog:update --default && pnpm catalog:update --next && pnpm up -iLr", diff --git a/packages/electrobun-cdp-bridge/README.md b/packages/electrobun-cdp-bridge/README.md new file mode 100644 index 000000000..3ae5b2a90 --- /dev/null +++ b/packages/electrobun-cdp-bridge/README.md @@ -0,0 +1,28 @@ +# @wdio/electrobun-cdp-bridge + +Multi-target Chrome DevTools Protocol (CDP) bridge for the +[`@wdio/electrobun-service`](../electrobun-service). It attaches to the CEF +renderer that an [Electrobun](https://electrobun.dev) app exposes and routes CDP +commands to a specific webview target. + +Unlike a single-target CDP client (e.g. `@wdio/electron-cdp-bridge`), Electrobun +apps surface **one CDP page target per webview** — a `BrowserWindow` shell and +each `BrowserView`/OOPIF content webview. This bridge discovers all of them from +the `/json` endpoint, classifies and labels them, and lets the service route +`execute`/mock/log traffic to the active target — backing the standard +`switchWindow` / `listWindows` surface. + +> **Status:** pre-release (`1.0.0-next.x`). Part of the staged Electrobun service +> rollout — this PR ships the package foundation (constants + types); the +> connection manager lands in the MVP PR. + +## Platform note + +CDP is only available when the Electrobun app is built with the **CEF renderer** +(`bundleCEF: true` / `defaultRenderer: 'cef'`). The default WebKit webviews +(WKWebView on macOS, WebKitGTK on Linux) do not speak CDP. See the service +README for details. + +## License + +MIT diff --git a/packages/electrobun-cdp-bridge/package.json b/packages/electrobun-cdp-bridge/package.json new file mode 100644 index 000000000..9b312f618 --- /dev/null +++ b/packages/electrobun-cdp-bridge/package.json @@ -0,0 +1,72 @@ +{ + "name": "@wdio/electrobun-cdp-bridge", + "version": "0.1.0-next.0", + "description": "Multi-target CDP Bridge for WebdriverIO Electrobun Service", + "author": "WebdriverIO Community", + "homepage": "https://github.com/webdriverio/desktop-mobile/tree/main/packages/electrobun-cdp-bridge", + "license": "MIT", + "type": "module", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": [ + { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./dist/cjs/index.js" + ] + }, + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "clean": "shx rm -rf dist coverage .turbo", + "build": "tsx ../../scripts/build-package.ts", + "dev": "tsx ../../scripts/build-package.ts --watch", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run --coverage", + "test:unit": "vitest run --coverage", + "test:dev": "vitest --coverage", + "lint": "biome check ." + }, + "dependencies": { + "@wdio/native-utils": "workspace:*", + "wait-port": "^1.1.0", + "ws": "^8.21.0" + }, + "devDependencies": { + "@types/node": "^25.9.1", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.1.7", + "cross-env": "^10.1.0", + "devtools-protocol": "^0.0.1634055", + "get-port": "^7.1.0", + "nock": "^14.0.15", + "shx": "^0.4.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "vitest": "^4.1.7" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/webdriverio/desktop-mobile.git", + "directory": "packages/electrobun-cdp-bridge" + } +} diff --git a/packages/electrobun-cdp-bridge/src/bridge.ts b/packages/electrobun-cdp-bridge/src/bridge.ts new file mode 100644 index 000000000..881c599cc --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/bridge.ts @@ -0,0 +1,227 @@ +import { createLogger } from '@wdio/native-utils'; + +import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; + +import { Connection } from './connection.js'; +import { + DEFAULT_HOSTNAME, + DEFAULT_MAX_RETRY_COUNT, + DEFAULT_PORT, + DEFAULT_RETRY_INTERVAL, + ERROR_MESSAGE, + REQUEST_TIMEOUT, +} from './constants.js'; +import { DevTool, type DevToolOptions } from './devTool.js'; +import { TargetRegistry } from './targetRegistry.js'; +import type { TargetRegistryEntry } from './types.js'; + +const log = createLogger('electrobun-cdp-bridge', 'bridge'); + +type Methods = keyof ProtocolMapping.Commands; +type Events = keyof ProtocolMapping.Events; +type MethodParams = ProtocolMapping.Commands[T]['paramsType']; +type MethodReturn = ProtocolMapping.Commands[T]['returnType']; +type SendParams = MethodParams extends [] ? [] : [MethodParams[number]]; + +export type CdpBridgeOptions = DevToolOptions & { + waitInterval?: number; + connectionRetryCount?: number; +}; + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Multi-target CDP client for an Electrobun CEF instance. Discovers every + * content webview target from `/json`, labels them via {@link TargetRegistry}, + * and routes commands to the active target — backing the service's + * `switchWindow`/`listWindows`. Unlike a single-target client it holds one + * {@link Connection} per attached target. + * + * **Invariant: never issues `Page.navigate`.** Attaching/switching only enables + * the Runtime domain on the live target; reloading would destroy app state. + */ +export class CdpBridge { + #options: Required; + #devTool: DevTool; + #registry = new TargetRegistry(); + #connections = new Map(); + #targets: TargetRegistryEntry[] = []; + #activeLabel: string | undefined; + #closed = false; + + constructor(options?: CdpBridgeOptions) { + // Per-field `??` rather than Object.assign: a caller passing an explicit + // `undefined` (e.g. an unset service option) must fall back to the default, not + // overwrite it. Object.assign copies undefined values, which left `timeout` + // undefined (so waitPort never actually waited → immediate-fail hammering) and + // `connectionRetryCount` undefined (so `retries >= undefined` never capped → + // effectively unbounded retries). + this.#options = { + host: options?.host ?? DEFAULT_HOSTNAME, + port: options?.port ?? DEFAULT_PORT, + timeout: options?.timeout ?? REQUEST_TIMEOUT, + waitInterval: options?.waitInterval ?? DEFAULT_RETRY_INTERVAL, + connectionRetryCount: options?.connectionRetryCount ?? DEFAULT_MAX_RETRY_COUNT, + }; + this.#devTool = new DevTool(this.#options); + } + + /** Discover content targets, then attach to the primary (`main`) target. */ + async connect(): Promise { + // A closed bridge must stay closed: a late connect() (e.g. a retry hook + // overlapping teardown) would open sockets nobody will ever close again. + if (this.#closed) { + throw new Error(ERROR_MESSAGE.BRIDGE_CLOSED); + } + await this.#discover(); + if (this.#targets.length === 0) { + throw new Error(ERROR_MESSAGE.NO_PAGE_TARGETS); + } + this.#activeLabel = this.#targets[0].label; + await this.#ensureConnection(this.#activeLabel); + } + + /** Re-enumerate targets (e.g. after a new window opens) and prune dead connections. */ + async refresh(): Promise { + const list = await this.#devTool.list(); + this.#targets = this.#registry.reconcile(list); + const live = new Set(this.#targets.map((target) => target.label)); + // Await the closes so in-flight requests on pruned connections are rejected before + // refresh() returns — makes teardown deterministic rather than fire-and-forget. + const closePromises: Promise[] = []; + for (const [label, connection] of this.#connections) { + if (!live.has(label)) { + closePromises.push(connection.close()); + this.#connections.delete(label); + } + } + await Promise.allSettled(closePromises); + // If the active target was pruned (its window closed), auto-advance to the + // first surviving target so subsequent send()/on() don't throw an opaque + // NOT_CONNECTED — callers can still switchTarget() explicitly. The survivor + // may never have been connected (discovered by an earlier refresh, never + // switched to), so open its connection too; best-effort — a transient + // connect failure surfaces on the next send(), not here. + if (this.#activeLabel && !live.has(this.#activeLabel)) { + this.#activeLabel = this.#targets[0]?.label; + if (this.#activeLabel) { + await this.#ensureConnection(this.#activeLabel).catch((error: Error) => { + log.warn(`Failed to connect auto-advanced target '${this.#activeLabel}': ${error.message}`); + }); + } + } + return [...this.#targets]; + } + + /** Live content targets (registration/label order). */ + listTargets(): TargetRegistryEntry[] { + return [...this.#targets]; + } + + /** Live content target labels — backs `browser.electrobun.listWindows()`. */ + listWindows(): string[] { + return this.#targets.map((target) => target.label); + } + + get activeLabel(): string | undefined { + return this.#activeLabel; + } + + /** CDP `/json/version` for the instance (CEF/Chromium version, for driver matching). */ + version() { + return this.#devTool.version(); + } + + /** Make `label` the active target — backs `browser.electrobun.switchWindow()`. */ + async switchTarget(label: string): Promise { + if (!this.#targets.some((target) => target.label === label)) { + throw new Error(`${ERROR_MESSAGE.TARGET_NOT_FOUND} ${label}`); + } + await this.#ensureConnection(label); + this.#activeLabel = label; + } + + /** Send a CDP command to the active target. */ + send(method: T, ...params: SendParams): Promise> { + return this.#active().send(method, ...params); + } + + /** Send a CDP command to a specific target (e.g. log capture attaching everywhere). */ + async sendTo(label: string, method: T, ...params: SendParams): Promise> { + const connection = await this.#ensureConnection(label); + return connection.send(method, ...params); + } + + /** Subscribe to a CDP event on the active target. */ + on(event: T, listener: (param: ProtocolMapping.Events[T][number]) => void): this { + this.#active().on(event, listener); + return this; + } + + async close(): Promise { + this.#closed = true; + await Promise.all([...this.#connections.values()].map((connection) => connection.close())); + this.#connections.clear(); + this.#activeLabel = undefined; + } + + async #discover(): Promise { + let retries = 0; + while (true) { + try { + const list = await this.#devTool.list(); + this.#targets = this.#registry.reconcile(list); + if (this.#targets.length > 0 || retries >= this.#options.connectionRetryCount) { + return; + } + } catch (error) { + if (retries >= this.#options.connectionRetryCount) { + throw error; + } + log.warn(`Target discovery attempt ${retries + 1} failed: ${(error as Error).message}`); + } + retries++; + await delay(this.#options.waitInterval); + } + } + + async #ensureConnection(label: string): Promise { + // Every lazy-connect path funnels through here — after close() a late + // switchTarget()/sendTo() must not re-open a socket nobody will close. + if (this.#closed) { + throw new Error(ERROR_MESSAGE.BRIDGE_CLOSED); + } + const existing = this.#connections.get(label); + if (existing) { + return existing; + } + const target = this.#targets.find((entry) => entry.label === label); + if (!target) { + throw new Error(`${ERROR_MESSAGE.TARGET_NOT_FOUND} ${label}`); + } + const connection = new Connection(target.webSocketDebuggerUrl, { timeout: this.#options.timeout }); + await connection.connect(); + try { + // Attach is observation-only — enable Runtime, never Page.navigate. + await connection.send('Runtime.enable'); + } catch (error) { + // Not yet tracked in #connections, so close() would never reach it — shut the + // live socket here or it leaks past bridge.close(). + await connection.close().catch(() => {}); + throw error; + } + this.#connections.set(label, connection); + return connection; + } + + #active(): Connection { + if (!this.#activeLabel) { + throw new Error(ERROR_MESSAGE.NOT_CONNECTED); + } + const connection = this.#connections.get(this.#activeLabel); + if (!connection) { + throw new Error(ERROR_MESSAGE.NOT_CONNECTED); + } + return connection; + } +} diff --git a/packages/electrobun-cdp-bridge/src/connection.ts b/packages/electrobun-cdp-bridge/src/connection.ts new file mode 100644 index 000000000..b97d44b03 --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/connection.ts @@ -0,0 +1,216 @@ +import EventEmitter from 'node:events'; +import { createLogger } from '@wdio/native-utils'; + +import type { ProtocolMapping } from 'devtools-protocol/types/protocol-mapping.js'; +import WebSocket from 'ws'; + +import { ERROR_MESSAGE, REQUEST_TIMEOUT } from './constants.js'; + +const log = createLogger('electrobun-cdp-bridge', 'bridge'); + +type Methods = keyof ProtocolMapping.Commands; +type Events = keyof ProtocolMapping.Events; +type MethodParams = ProtocolMapping.Commands[T]['paramsType']; +type MethodReturn = ProtocolMapping.Commands[T]['returnType']; +type SendParams = MethodParams extends [] ? [] : [MethodParams[number]]; + +type PromiseHandlers = { + // biome-ignore lint/suspicious/noExplicitAny: holds Promise executors of differing T; `unknown` fails strictFunctionTypes contravariance + resolve: (value?: any) => void; + reject: (reason?: unknown) => void; + timer?: ReturnType; +}; + +type EventValue = { + method: string; + params: unknown; + sessionId?: string; +}; + +type MethodReturnValue = { + id: number; + result?: { result: unknown }; + error?: { message: string }; +}; + +// Reserved for the connect() promise: command IDs are pre-incremented from 0, +// so send() emits 1, 2, … and a response can never carry id 0. +const CONNECT_PROMISE_ID = 0; + +/** + * A single CDP WebSocket connection to one CEF page target. Extracted from + * `@wdio/electron-cdp-bridge`'s `CdpBridge`, but constructed from an explicit + * `webSocketDebuggerUrl` (the multi-target `CdpBridge` owns target discovery). + * + * Deliberately exposes **no navigate helper** — attaching to a live Electrobun + * webview must be observation/input only; issuing `Page.navigate` would reload + * the target and destroy the app's state. Callers send arbitrary CDP methods via + * `send()`, but the bridge never sends `Page.navigate` on attach/switch. + */ +export class Connection extends EventEmitter { + readonly webSocketDebuggerUrl: string; + #timeout: number; + #ws: WebSocket | null = null; + #promises = new Map(); + #commandId = CONNECT_PROMISE_ID; + #closeReason: Error | undefined; + + constructor(webSocketDebuggerUrl: string, options?: { timeout?: number }) { + super(); + this.webSocketDebuggerUrl = webSocketDebuggerUrl; + this.#timeout = options?.timeout ?? REQUEST_TIMEOUT; + } + + get state() { + return !this.#ws ? undefined : this.#ws.readyState; + } + + connect(): Promise { + return new Promise((resolve, reject) => { + if (this.#ws) { + resolve(); + return; + } + log.debug(`Connecting: ${this.webSocketDebuggerUrl}`); + this.#ws = new WebSocket(this.webSocketDebuggerUrl, [], { + maxPayload: 256 * 1024 * 1024, + perMessageDeflate: false, + followRedirects: true, + handshakeTimeout: this.#timeout, + }); + this.#promises.set(CONNECT_PROMISE_ID, { resolve, reject }); + this.#setHandlers(this.#ws); + }); + } + + on(event: T, listener: (param: ProtocolMapping.Events[T][number]) => void): this { + return super.on(event, listener); + } + + send(method: T, ...params: SendParams): Promise> { + this.#commandId = this.#commandId + 1; + const messageId = this.#commandId; + const message = { id: messageId, method, params: params[0] ?? {} }; + return new Promise((resolve, reject) => { + if (!this.#ws || this.#ws.readyState !== WebSocket.OPEN) { + reject(new Error(ERROR_MESSAGE.NOT_CONNECTED)); + return; + } + const timer = setTimeout(() => { + if (this.#promises.has(message.id)) { + this.#promises.delete(message.id); + reject(new Error(`${ERROR_MESSAGE.TIMEOUT_CONNECTION} ${message.id}`)); + } + }, this.#timeout); + this.#promises.set(message.id, { resolve, reject, timer }); + this.#ws.send(JSON.stringify(message)); + }); + } + + close() { + return this.#close(); + } + + #setHandlers(ws: WebSocket) { + ws.on('open', () => { + log.debug(`Connected to ${this.webSocketDebuggerUrl}`); + this.#promises.get(CONNECT_PROMISE_ID)?.resolve(); + this.#promises.delete(CONNECT_PROMISE_ID); + }); + ws.on('message', (rawMessage) => { + try { + this.#messageHandler(rawMessage.toString()); + } catch (error) { + log.error('Message handling error'); + void this.#errorHandler(error); + } + }); + ws.on('error', (error) => { + log.error('WebSocket error'); + void this.#errorHandler(error); + }); + ws.on('close', () => { + log.trace(ERROR_MESSAGE.CONNECTION_CLOSED); + this.#rejectAllPromises(this.#closeReason); + this.#closeReason = undefined; + this.#ws = null; + }); + } + + #messageHandler(strMessage: string) { + const message = parseJson(strMessage); + if (message.id) { + this.#responseHandler(message); + } else if (message.method) { + this.#eventHandler(message); + } + } + + #responseHandler(message: MethodReturnValue) { + const handler = this.#promises.get(message.id); + if (!handler) { + return; + } + if (handler.timer) { + clearTimeout(handler.timer); + } + if (message.error) { + handler.reject(new Error(message.error.message)); + } else { + handler.resolve(message.result); + } + this.#promises.delete(message.id); + } + + #eventHandler(message: EventValue) { + const { method, params, sessionId } = message; + this.emit(method, params, sessionId); + } + + async #errorHandler(error: unknown) { + log.error((error as Error).message); + return await this.#close(error); + } + + #close(error?: unknown) { + return new Promise((resolve) => { + if (this.#ws && this.#ws.readyState !== WebSocket.CLOSED) { + // Reject pending promises via the single 'close' handler (it fires on + // ws.close() below), passing the error reason through #closeReason — + // avoids rejecting them here AND again in the handler. First reason + // wins: a racing plain close() must not blank an error-path reason. + this.#closeReason ??= error as Error | undefined; + this.#ws.once('close', () => { + this.#ws = null; + resolve(); + }); + this.#ws.close(); + } else { + // No open socket → no 'close' event coming; reject directly. + this.#rejectAllPromises(error as Error | undefined); + this.#ws = null; + resolve(); + } + }); + } + + #rejectAllPromises(error?: Error) { + const message = error ? `${ERROR_MESSAGE.ERROR_INTERNAL} ${error.message}` : ERROR_MESSAGE.CONNECTION_CLOSED; + const reason = new Error(message); + this.#promises.forEach((handler) => { + if (handler.timer) { + clearTimeout(handler.timer); + } + handler.reject(reason); + }); + this.#promises.clear(); + } +} + +const parseJson = (strJson: string) => { + try { + return JSON.parse(strJson); + } catch (error) { + throw new Error(`${ERROR_MESSAGE.ERROR_PARSE_JSON} ${(error as Error).message}`); + } +}; diff --git a/packages/electrobun-cdp-bridge/src/constants.ts b/packages/electrobun-cdp-bridge/src/constants.ts new file mode 100644 index 000000000..966f33d15 --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/constants.ts @@ -0,0 +1,30 @@ +export const REQUEST_TIMEOUT = 10000; +export const DEFAULT_HOSTNAME = 'localhost'; + +/** + * CEF's default remote-debugging port and the auto-scan range its renderer uses + * when an app pins no port (`FindAvailableRemoteDebugPort(9222, 9232)` in the + * native wrapper). The service launcher does NOT rely on the scan: it pins a + * specific port per worker by writing `chromiumFlags.remote-debugging-port` into + * that worker's bundle `build.json`, so the bridge connects to a known + * `host:port`. This constant is the connection default/fallback. + */ +export const DEFAULT_PORT = 9222; +export const DEFAULT_PORT_RANGE_START = 9222; +export const DEFAULT_PORT_RANGE_END = 9232; + +export const DEFAULT_MAX_RETRY_COUNT = 3; +export const DEFAULT_RETRY_INTERVAL = 100; + +export const ERROR_MESSAGE = { + TIMEOUT_CONNECTION: 'Request timeout exceeded waiting for response:', + TIMEOUT_WAIT_PORT: 'Timeout exceeded while waiting for debugger port to open', + DEBUGGER_NOT_FOUND: 'No debugger instance was detected', + NO_PAGE_TARGETS: 'No CDP page targets were detected at the debugger endpoint', + BRIDGE_CLOSED: 'CdpBridge is closed — create a new bridge to reconnect', + TARGET_NOT_FOUND: 'No CDP target is registered for label:', + NOT_CONNECTED: "WebSocket is not connected. Call 'CdpBridge.connect()' before using this method", + CONNECTION_CLOSED: 'WebSocket connection has been closed', + ERROR_PARSE_JSON: 'Failed to parse JSON response:', + ERROR_INTERNAL: 'Connection closed due to error:', +} as const; diff --git a/packages/electrobun-cdp-bridge/src/devTool.ts b/packages/electrobun-cdp-bridge/src/devTool.ts new file mode 100644 index 000000000..2c713bb70 --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/devTool.ts @@ -0,0 +1,139 @@ +import http, { type ClientRequest, type RequestOptions } from 'node:http'; +import { createLogger } from '@wdio/native-utils'; + +import waitPort from 'wait-port'; + +import { DEFAULT_HOSTNAME, DEFAULT_PORT, ERROR_MESSAGE, REQUEST_TIMEOUT } from './constants.js'; +import type { DebuggerList, Version } from './types.js'; + +const log = createLogger('electrobun-cdp-bridge', 'bridge'); + +export type DevToolOptions = { + host?: string; + port?: number; + timeout?: number; +}; + +type DevToolRequestOptions = RequestOptions & { + path: string; +}; + +type VersionReturnValue = { + Browser: string; + 'Protocol-Version': string; +}; + +type WaitOptions = Parameters[0]; + +const BRIDGE_RETRY_INTERVAL = 100; + +/** + * Discovers CDP targets from a CEF instance's HTTP debugger endpoint. Mirrors + * `@wdio/electron-cdp-bridge`'s DevTool, but the consumer keeps **every** + * `type: 'page'` entry `list()` returns (CEF exposes one per webview), instead + * of using only the first. + */ +export class DevTool { + #options: Required; + #isPortOpened = false; + + constructor(options?: DevToolOptions) { + // `??` (not Object.assign): an explicit `{ timeout: undefined }` from a caller must + // fall through to the default, not clobber it — same contract as CdpBridge. + this.#options = { + host: options?.host ?? DEFAULT_HOSTNAME, + port: options?.port ?? DEFAULT_PORT, + timeout: options?.timeout ?? REQUEST_TIMEOUT, + }; + } + + list() { + return this.#executeRequest({ path: '/json' }); + } + + async version(): Promise { + const result = await this.#executeRequest({ path: '/json/version' }); + log.info(`Browser: ${result.Browser}, Protocol: ${result['Protocol-Version']}`); + return { + browser: result.Browser, + protocolVersion: result['Protocol-Version'], + }; + } + + #waitDebuggerPort() { + return new Promise((resolve, reject) => { + if (!this.#isPortOpened) { + const waitOptions: WaitOptions = { + ...this.#options, + output: 'silent', + interval: BRIDGE_RETRY_INTERVAL, + }; + waitPort(waitOptions) + .then(() => { + this.#isPortOpened = true; + resolve(); + }) + .catch(reject); + } else { + resolve(); + } + }); + } + + async #executeRequest(options: DevToolRequestOptions): Promise { + const resolvedOptions: RequestOptions = Object.assign({}, this.#options, options); + log.debug('Request to the debugger', resolvedOptions); + return new Promise((resolve, reject) => { + // Order matters: run the request only AFTER the port opens. A `.catch().then()` + // chain would still run the `.then()` after the port-wait rejected (catch + // resolves), firing a request at a port that never opened. + this.#waitDebuggerPort() + .then(() => { + const req = http.request(resolvedOptions, (res) => { + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + if (res.statusCode === 200) { + const message = JSON.parse(data); + log.trace('Received response: ', message); + resolve(message); + } else { + reject(new Error(data)); + } + } catch (error) { + reject(error); + } + }); + }); + + // req.setTimeout alone covers the socket for the request's lifetime — a + // socket-level 'timeout' listener would just double-fire #timeoutHandler. + req.setTimeout(this.#options.timeout, () => { + this.#timeoutHandler(reject, req); + }); + + req.on('error', (error) => { + reject(new Error(`Request Error: ${error.message}`)); + }); + + req.end(); + }) + .catch(() => { + reject(new Error(ERROR_MESSAGE.TIMEOUT_WAIT_PORT)); + }); + }); + } + + #timeoutHandler(reject: (reason?: Error) => void, request: ClientRequest) { + request.destroy(); + const message = `${ERROR_MESSAGE.TIMEOUT_CONNECTION} ${getReqInfo(request)}`; + log.trace(message); + reject(new Error(message)); + } +} + +const getReqInfo = (request: ClientRequest) => + `${request.method} ${request.protocol}//${request.getHeader('Host')}${request.path}`; diff --git a/packages/electrobun-cdp-bridge/src/index.ts b/packages/electrobun-cdp-bridge/src/index.ts new file mode 100644 index 000000000..af73bde0b --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/index.ts @@ -0,0 +1,11 @@ +// @wdio/electrobun-cdp-bridge — multi-target CDP client for Electrobun's CEF +// renderer: discovers every content webview target, labels them, and routes +// commands to the active target (backing switchWindow/listWindows). Never +// issues Page.navigate on attach. + +export * from './bridge.js'; +export * from './connection.js'; +export * from './constants.js'; +export * from './devTool.js'; +export * from './targetRegistry.js'; +export * from './types.js'; diff --git a/packages/electrobun-cdp-bridge/src/targetRegistry.ts b/packages/electrobun-cdp-bridge/src/targetRegistry.ts new file mode 100644 index 000000000..cd4fb8ffb --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/targetRegistry.ts @@ -0,0 +1,100 @@ +import type { Debugger, DebuggerList, PageTarget, TargetClass, TargetRegistryEntry } from './types.js'; + +const CONTENT_SCHEME_PREFIXES = ['views://', 'http://', 'https://', 'file://']; +const NON_CONTENT_PREFIXES = ['devtools://', 'chrome://', 'chrome-extension://', 'chrome-untrusted://']; + +/** + * Classify a discovered CDP target. Pure + unit-tested in isolation because the + * discriminator is the part most likely to drift across Electrobun versions. + * + * Per the Phase 0 spike, Electrobun CEF windows surface as `type: 'page'` under + * the custom `views://` scheme with no separate shell target; we still keep the + * `shell`/`other` branches for robustness and fail **open** to `content` for an + * unknown page scheme so a real app webview is never hidden from the user. + */ +export function classifyTarget(target: Pick): TargetClass { + if (target.type !== 'page') { + return 'other'; + } + const url = target.url ?? ''; + if (NON_CONTENT_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return 'other'; + } + if (url === '' || url === 'about:blank') { + return 'shell'; + } + if (CONTENT_SCHEME_PREFIXES.some((prefix) => url.startsWith(prefix))) { + return 'content'; + } + return 'content'; +} + +function toPageTarget(target: Debugger): PageTarget { + return { + id: target.id, + title: target.title, + url: target.url, + webSocketDebuggerUrl: target.webSocketDebuggerUrl, + class: classifyTarget(target), + }; +} + +function byCodepoint(x: string, y: string): number { + if (x < y) { + return -1; + } + return x > y ? 1 : 0; +} + +function labelOrder(label: string): number { + return label === 'main' ? -1 : Number.parseInt(label.replace('window-', ''), 10); +} + +/** + * Tracks the user-facing label for each content target. Labels are stable: the + * first content target seen becomes `main`, then `window-1`, `window-2`…, keyed + * on the CEF target `id` so re-enumeration never renumbers a live target. The + * counter is monotonic — a closed-then-reopened window gets a fresh label rather + * than reclaiming a stale one. + */ +export class TargetRegistry { + #labelById = new Map(); + #counter = 0; + + /** Reconcile against a fresh `/json` listing; returns live content targets in label order. */ + reconcile(list: DebuggerList): TargetRegistryEntry[] { + // Sort by URL before assigning labels: CEF's /json order isn't stable, so the + // first content target (which becomes `main`) would otherwise flip between + // windows across runs. URL order is deterministic and puts the primary window + // (`views://mainview/…`) ahead of secondary ones (`views://secondview/…`), so + // `main` is consistently the app's main window. Tie-break on the stable CEF target + // `id` so same-URL windows (e.g. a tiled layout) still sort deterministically. + // Codepoint comparison, not localeCompare — the latter follows the runtime's + // locale, so the order could differ across environments. + const content = list + .map(toPageTarget) + .filter((target) => target.class === 'content') + .sort((a, b) => byCodepoint(a.url, b.url) || byCodepoint(a.id, b.id)); + const liveIds = new Set(content.map((target) => target.id)); + + for (const target of content) { + if (!this.#labelById.has(target.id)) { + this.#labelById.set(target.id, this.#counter === 0 ? 'main' : `window-${this.#counter}`); + this.#counter++; + } + } + for (const id of [...this.#labelById.keys()]) { + if (!liveIds.has(id)) { + this.#labelById.delete(id); + } + } + + return content + .map((target) => ({ ...target, label: this.#labelById.get(target.id) as string })) + .sort((a, b) => labelOrder(a.label) - labelOrder(b.label)); + } + + labelFor(id: string): string | undefined { + return this.#labelById.get(id); + } +} diff --git a/packages/electrobun-cdp-bridge/src/types.ts b/packages/electrobun-cdp-bridge/src/types.ts new file mode 100644 index 000000000..14f424d08 --- /dev/null +++ b/packages/electrobun-cdp-bridge/src/types.ts @@ -0,0 +1,56 @@ +/** + * A single entry from the CDP `/json` discovery endpoint. Electrobun's CEF + * renderer exposes one entry per webview (a `BrowserWindow` shell or a + * `BrowserView`/OOPIF content webview), so unlike Electron we keep *all* the + * `type === 'page'` entries rather than just the first. + */ +export type Debugger = { + description: string; + devtoolsFrontendUrl: string; + devtoolsFrontendUrlCompat: string; + faviconUrl: string; + id: string; + title: string; + type: string; + url: string; + webSocketDebuggerUrl: string; +}; + +export type Version = { + browser: string; + protocolVersion: string; +}; + +export type DebuggerList = Array; + +/** + * Classification of a discovered CDP target. + * - `content` — an app webview (a `BrowserWindow`/`BrowserView` rendering app + * content); these are surfaced via `switchWindow`/`listWindows`. + * - `shell` — a host/chrome target (e.g. `about:blank`) not surfaced to users. + * - `other` — devtools, service workers, and anything else ignored for routing. + * + * The discriminator (URL scheme / path) lives in `classifyTarget` (targetRegistry.ts). + */ +export type TargetClass = 'content' | 'shell' | 'other'; + +/** + * A discovered CDP page target plus the bridge's derived routing metadata. The + * stable identity is the CEF target `id`; `label` is persisted against it so + * re-enumeration doesn't renumber a live target. + */ +export type PageTarget = { + id: string; + title: string; + url: string; + webSocketDebuggerUrl: string; + class: TargetClass; +}; + +/** + * An entry in the bridge's target registry: the discovered target plus the + * user-facing label (`main`, `window-1`, …) the multi-window API maps onto. + */ +export type TargetRegistryEntry = PageTarget & { + label: string; +}; diff --git a/packages/electrobun-cdp-bridge/test/bridge.spec.ts b/packages/electrobun-cdp-bridge/test/bridge.spec.ts new file mode 100644 index 000000000..8c13e6e38 --- /dev/null +++ b/packages/electrobun-cdp-bridge/test/bridge.spec.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Debugger } from '../src/types.js'; + +// Shared mutable state for the mocked DevTool/Connection (hoisted so the +// vi.mock factories can reference it). +const h = vi.hoisted(() => ({ targets: [] as Debugger[], sent: [] as string[], failEnable: false, closed: 0 })); + +vi.mock('../src/devTool.js', () => ({ + DevTool: class { + list = async () => h.targets; + version = async () => ({ browser: 'CEF', protocolVersion: '1.3' }); + }, +})); + +vi.mock('../src/connection.js', () => ({ + Connection: class { + webSocketDebuggerUrl: string; + constructor(url: string) { + this.webSocketDebuggerUrl = url; + } + connect = async () => {}; + send = async (method: string) => { + h.sent.push(method); + if (h.failEnable && method === 'Runtime.enable') { + throw new Error('Runtime.enable failed'); + } + return {}; + }; + on = () => this; + close = async () => { + h.closed++; + }; + }, +})); + +import { CdpBridge } from '../src/bridge.js'; + +const target = (id: string, url: string): Debugger => ({ + id, + url, + type: 'page', + title: `title-${id}`, + description: '', + devtoolsFrontendUrl: '', + devtoolsFrontendUrlCompat: '', + faviconUrl: '', + webSocketDebuggerUrl: `ws://localhost:9222/devtools/page/${id}`, +}); + +beforeEach(() => { + h.targets = []; + h.sent.length = 0; + h.failEnable = false; + h.closed = 0; +}); + +describe('CdpBridge multi-target routing', () => { + it('should attach to the main target on connect and enable Runtime but never navigate', async () => { + h.targets = [target('A', 'views://mainview/index.html'), target('B', 'views://secondview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + + expect(bridge.activeLabel).toBe('main'); + expect(bridge.listWindows()).toEqual(['main', 'window-1']); + expect(h.sent).toContain('Runtime.enable'); + expect(h.sent).not.toContain('Page.navigate'); + }); + + it('should close the freshly opened connection when Runtime.enable fails', async () => { + h.targets = [target('A', 'views://mainview/index.html')]; + h.failEnable = true; + const bridge = new CdpBridge({ connectionRetryCount: 0, waitInterval: 1 }); + + await expect(bridge.connect()).rejects.toThrow('Runtime.enable failed'); + + // The connection was never tracked in the bridge, so it must be closed at the + // failure site — otherwise the live socket would leak past bridge.close(). + expect(h.closed).toBe(1); + }); + + it('should refuse connect() after close()', async () => { + h.targets = [target('A', 'views://mainview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + await bridge.close(); + + await expect(bridge.connect()).rejects.toThrow(/closed/); + }); + + it('should refuse switchTarget() after close() (no re-opened sockets)', async () => { + h.targets = [target('A', 'views://mainview/index.html'), target('B', 'views://secondview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + await bridge.close(); + + await expect(bridge.switchTarget('window-1')).rejects.toThrow(/closed/); + }); + + it('should connect the auto-advanced target when the active window closes', async () => { + h.targets = [target('A', 'views://mainview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + + // A later refresh discovers window-1 but nothing ever switches to it… + h.targets = [target('A', 'views://mainview/index.html'), target('B', 'views://secondview/index.html')]; + await bridge.refresh(); + + // …then main closes: the auto-advance must also open the survivor's + // connection, or send()/on() would throw NOT_CONNECTED. + h.targets = [target('B', 'views://secondview/index.html')]; + await bridge.refresh(); + + expect(bridge.activeLabel).toBe('window-1'); + await expect(bridge.send('Runtime.enable')).resolves.toBeDefined(); + }); + + it('should switch the active target without navigating', async () => { + h.targets = [target('A', 'views://mainview/index.html'), target('B', 'views://secondview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + + await bridge.switchTarget('window-1'); + + expect(bridge.activeLabel).toBe('window-1'); + expect(h.sent).not.toContain('Page.navigate'); + }); + + it('should expose live content targets via listTargets', async () => { + h.targets = [target('A', 'views://mainview/index.html'), target('B', 'views://secondview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + + const labels = bridge.listTargets().map((entry) => entry.label); + expect(labels).toEqual(['main', 'window-1']); + }); + + it('should reject switching to an unknown label', async () => { + h.targets = [target('A', 'views://mainview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + + await expect(bridge.switchTarget('window-9')).rejects.toThrow(); + }); + + it('should throw when no content targets are discovered', async () => { + h.targets = [target('A', 'about:blank')]; + const bridge = new CdpBridge({ connectionRetryCount: 0 }); + + await expect(bridge.connect()).rejects.toThrow(); + }); + + it('should auto-advance the active target when the active window is pruned on refresh', async () => { + h.targets = [target('A', 'views://mainview/index.html'), target('B', 'views://secondview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + expect(bridge.activeLabel).toBe('main'); + + h.targets = [target('B', 'views://secondview/index.html')]; // 'main' (A) closed + await bridge.refresh(); + + expect(bridge.activeLabel).toBe('window-1'); + }); + + it('should clear the active target when all targets disappear on refresh', async () => { + h.targets = [target('A', 'views://mainview/index.html')]; + const bridge = new CdpBridge(); + await bridge.connect(); + + h.targets = []; + await bridge.refresh(); + + expect(bridge.activeLabel).toBeUndefined(); + }); +}); diff --git a/packages/electrobun-cdp-bridge/test/connection.spec.ts b/packages/electrobun-cdp-bridge/test/connection.spec.ts new file mode 100644 index 000000000..0830f7555 --- /dev/null +++ b/packages/electrobun-cdp-bridge/test/connection.spec.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Registry of mock sockets so tests can drive the ws lifecycle (open/error/close) +// manually — the close() mock only moves readyState to CLOSING; the test decides +// when the 'close' event actually fires, which is what the race below needs. +const h = vi.hoisted(() => ({ sockets: [] as unknown[] })); + +vi.mock('ws', async () => { + const { EventEmitter } = await import('node:events'); + class MockWebSocket extends EventEmitter { + static OPEN = 1; + static CLOSED = 3; + readyState = MockWebSocket.OPEN; + constructor(..._args: unknown[]) { + super(); + h.sockets.push(this); + } + send(_data: string) {} + close() { + this.readyState = 2; // CLOSING — 'close' is emitted by the test + } + } + return { default: MockWebSocket }; +}); + +import { Connection } from '../src/connection.js'; + +type DrivableSocket = { + emit: (event: string, ...args: unknown[]) => boolean; +}; + +describe('Connection close-reason handling', () => { + beforeEach(() => { + h.sockets.length = 0; + }); + + it('should keep the first close reason when a plain close() races an error close', async () => { + const connection = new Connection('ws://127.0.0.1:9222/devtools/page/A'); + const connectPromise = connection.connect(); + const ws = h.sockets.at(-1) as DrivableSocket; + ws.emit('open'); + await connectPromise; + + const pending = connection.send('Runtime.enable'); + pending.catch(() => {}); // settled later — avoid an unhandled-rejection blip + + // Error-path close sets the reason; the racing plain close() must not blank + // it before the single 'close' event rejects the pending promises. + ws.emit('error', new Error('boom')); + const closePromise = connection.close(); + ws.emit('close'); + await closePromise; + + await expect(pending).rejects.toThrow(/boom/); + }); +}); diff --git a/packages/electrobun-cdp-bridge/test/index.spec.ts b/packages/electrobun-cdp-bridge/test/index.spec.ts new file mode 100644 index 000000000..b4b236d69 --- /dev/null +++ b/packages/electrobun-cdp-bridge/test/index.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +describe('@wdio/electrobun-cdp-bridge public exports', () => { + it("should default the debugger port to CEF's 9222", async () => { + const { DEFAULT_PORT } = await import('../src/index.js'); + expect(DEFAULT_PORT).toBe(9222); + }); + + it('should expose the CEF auto-select port range (9222-9232)', async () => { + const { DEFAULT_PORT_RANGE_START, DEFAULT_PORT_RANGE_END } = await import('../src/index.js'); + expect(DEFAULT_PORT_RANGE_START).toBe(9222); + expect(DEFAULT_PORT_RANGE_END).toBe(9232); + expect(DEFAULT_PORT_RANGE_END).toBeGreaterThan(DEFAULT_PORT_RANGE_START); + }); + + it('should expose the bridge error messages', async () => { + const { ERROR_MESSAGE } = await import('../src/index.js'); + expect(ERROR_MESSAGE.NO_PAGE_TARGETS).toBeTypeOf('string'); + expect(ERROR_MESSAGE.TARGET_NOT_FOUND).toBeTypeOf('string'); + expect(ERROR_MESSAGE.NOT_CONNECTED).toContain('CdpBridge.connect()'); + }); +}); diff --git a/packages/electrobun-cdp-bridge/test/targetRegistry.spec.ts b/packages/electrobun-cdp-bridge/test/targetRegistry.spec.ts new file mode 100644 index 000000000..297edfef8 --- /dev/null +++ b/packages/electrobun-cdp-bridge/test/targetRegistry.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { classifyTarget, TargetRegistry } from '../src/targetRegistry.js'; +import type { Debugger } from '../src/types.js'; + +const mk = (id: string, url: string, type = 'page'): Debugger => ({ + id, + url, + type, + title: `title-${id}`, + description: '', + devtoolsFrontendUrl: '', + devtoolsFrontendUrlCompat: '', + faviconUrl: '', + webSocketDebuggerUrl: `ws://localhost:9222/devtools/page/${id}`, +}); + +describe('classifyTarget', () => { + it('should classify a views:// page as content', () => { + expect(classifyTarget({ type: 'page', url: 'views://mainview/index.html' })).toBe('content'); + }); + + it('should classify an http(s) dev-server page as content', () => { + expect(classifyTarget({ type: 'page', url: 'http://localhost:5173/' })).toBe('content'); + }); + + it('should classify about:blank as a shell target', () => { + expect(classifyTarget({ type: 'page', url: 'about:blank' })).toBe('shell'); + }); + + it('should classify devtools/chrome pages as other', () => { + expect(classifyTarget({ type: 'page', url: 'devtools://devtools/bundled/inspector.html' })).toBe('other'); + expect(classifyTarget({ type: 'page', url: 'chrome://gpu' })).toBe('other'); + }); + + it('should classify non-page targets as other', () => { + expect(classifyTarget({ type: 'service_worker', url: 'views://mainview/sw.js' })).toBe('other'); + }); + + it('should fail open to content for an unknown page scheme', () => { + expect(classifyTarget({ type: 'page', url: 'app://weird/path' })).toBe('content'); + }); +}); + +describe('TargetRegistry', () => { + it('should label the first content target main and the rest window-N', () => { + const registry = new TargetRegistry(); + const entries = registry.reconcile([ + mk('A', 'views://mainview/index.html'), + mk('B', 'views://secondview/index.html'), + ]); + expect(entries.map((entry) => entry.label)).toEqual(['main', 'window-1']); + }); + + it('should make main the URL-first content target regardless of /json order', () => { + const registry = new TargetRegistry(); + // CEF lists secondview first here, but URL order is deterministic, so mainview + // (sorts before secondview) is consistently `main` — not whatever came first. + const entries = registry.reconcile([ + mk('B', 'views://secondview/index.html'), + mk('A', 'views://mainview/index.html'), + ]); + expect(entries.find((entry) => entry.label === 'main')?.url).toBe('views://mainview/index.html'); + }); + + it('should exclude non-content targets and still label the first content one main', () => { + const registry = new TargetRegistry(); + const entries = registry.reconcile([ + mk('A', 'about:blank'), + mk('B', 'views://mainview/index.html'), + mk('C', 'devtools://devtools/x.html'), + ]); + expect(entries.map((entry) => entry.id)).toEqual(['B']); + expect(entries[0].label).toBe('main'); + }); + + it('should keep labels stable across re-enumeration regardless of order', () => { + const registry = new TargetRegistry(); + registry.reconcile([mk('A', 'views://m'), mk('B', 'views://s')]); + const again = registry.reconcile([mk('B', 'views://s'), mk('A', 'views://m')]); + expect(again.find((entry) => entry.id === 'A')?.label).toBe('main'); + expect(again.find((entry) => entry.id === 'B')?.label).toBe('window-1'); + }); + + it('should deterministically order same-URL windows via the id tie-break', () => { + // Two windows loading the identical URL (e.g. a tiled layout) — localeCompare on URL + // returns 0, so without the id tie-break main/window-1 could swap across refreshes. + const registry = new TargetRegistry(); + const first = registry.reconcile([mk('B', 'views://same'), mk('A', 'views://same')]); + const second = registry.reconcile([mk('A', 'views://same'), mk('B', 'views://same')]); + // 'A' sorts before 'B' by id regardless of /json order, so it is consistently `main`. + expect(first.find((entry) => entry.id === 'A')?.label).toBe('main'); + expect(second.find((entry) => entry.id === 'A')?.label).toBe('main'); + expect(first.find((entry) => entry.id === 'B')?.label).toBe('window-1'); + }); + + it('should not reclaim a stale label after a window closes', () => { + const registry = new TargetRegistry(); + registry.reconcile([mk('A', 'views://m'), mk('B', 'views://s')]); + registry.reconcile([mk('A', 'views://m')]); + const reopened = registry.reconcile([mk('A', 'views://m'), mk('C', 'views://t')]); + expect(reopened.find((entry) => entry.id === 'A')?.label).toBe('main'); + expect(reopened.find((entry) => entry.id === 'C')?.label).toBe('window-2'); + }); +}); diff --git a/packages/electrobun-cdp-bridge/tsconfig.json b/packages/electrobun-cdp-bridge/tsconfig.json new file mode 100644 index 000000000..ab0e1bf7e --- /dev/null +++ b/packages/electrobun-cdp-bridge/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/packages/electrobun-cdp-bridge/vitest.config.ts b/packages/electrobun-cdp-bridge/vitest.config.ts new file mode 100644 index 000000000..fb724e16f --- /dev/null +++ b/packages/electrobun-cdp-bridge/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.spec.ts'], + exclude: [...configDefaults.exclude], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['**/*.d.ts', '**/types.ts', 'src/index.ts'], + }, + }, +}); diff --git a/packages/electrobun-service/README.md b/packages/electrobun-service/README.md new file mode 100644 index 000000000..e75e60dfe --- /dev/null +++ b/packages/electrobun-service/README.md @@ -0,0 +1,113 @@ +# @wdio/electrobun-service + +WebdriverIO service for end-to-end testing [Electrobun](https://electrobun.dev) +desktop applications — the TypeScript-first, Bun-powered desktop framework. + +It mirrors the surface of the sibling services (`@wdio/electron-service`, +`@wdio/tauri-service`, `@wdio/dioxus-service`): `browser.electrobun.execute`, +Vitest-style mocking, log capture, browser mode, and standalone sessions. + +> **Status: `0.1.0` — macOS-only, pre-1.0.** This is a `0.x` release because the +> CEF renderer Electrobun apps must be built with cannot currently be driven on +> Linux/Windows, and multiremote/multi-window/deeplink are blocked by the same +> upstream limitation. See [Known limitations](#known-limitations). `1.0` is +> reserved for full parity once those gaps are filled +> ([#317](https://github.com/webdriverio/desktop-mobile/issues/317) tracks the work). + +## Installation + +```sh +npm install --save-dev @wdio/electrobun-service +``` + +## Platform requirement: the CEF renderer + +This is a **CDP-attach** service — it drives the app through the Chrome DevTools +Protocol (the launcher spawns the app binary and WebdriverIO attaches via +Chromedriver's `debuggerAddress`). Electrobun's default webview engine differs +per platform and only the Chromium-based ones speak CDP: + +| Platform | Default webview | CDP? | +|---|---|---| +| Windows | WebView2 (Chromium) | ✅ | +| macOS | WKWebView (WebKit) | ❌ | +| Linux | WebKitGTK (WebKit) | ❌ | + +So the service requires the app under test to be built with Electrobun's **CEF +renderer**: + +```ts +// electrobun.config.ts +export default { + build: { + mac: { bundleCEF: true, defaultRenderer: 'cef' }, + }, +}; +``` + +Apps built with the default WebKit renderer are an explicit, documented +unsupported configuration — the launcher fails fast with a clear error. + +## Quick start + +```ts +// wdio.conf.ts +export const config = { + services: ['electrobun'], + capabilities: [ + { + // The launcher rewrites this to 'chrome' + sets the CDP debuggerAddress. + browserName: 'electrobun', + // Pin Chromedriver to the CEF Chromium major (147 for current Electrobun). + browserVersion: '147', + 'wdio:electrobunServiceOptions': { + appBinaryPath: '/path/to/build//MyApp.app', + }, + }, + ], + // ...mocha/spec config +}; +``` + +```ts +// a spec +const title = await browser.electrobun.execute(() => document.title); +expect(title).toBe('My App'); +``` + +## Supported surface (macOS) + +| Feature | Status | +|---|---| +| `execute` | ✅ | +| mocking (`mock` + `clear`/`reset`/`restoreAllMocks` + `isMockFunction`) | ✅ | +| frontend + backend log capture | ✅ | +| browser mode (`mode: 'browser'` against a dev server) | ✅ | +| standalone / session mode | ✅ | + +## Known limitations + +These are **upstream Electrobun/CEF limitations**, not service bugs — the service +code implements the full surface and is unit-tested. CEF's chrome-runtime can't +create the `persist:default` profile its `BrowserWindow` forces and falls back to +a global browser context; macOS recovers (serves `/json`), but Linux/Windows do +not. The upstream CEF fixes and what each unblocks are tracked in +[#320](https://github.com/webdriverio/desktop-mobile/issues/320); the non-CEF +(native-renderer) track that fills Linux/Windows a different way is +[#317](https://github.com/webdriverio/desktop-mobile/issues/317). + +| Area | Status | +|---|---| +| **Linux / Windows** | ❌ unsupported — CEF exposes no reachable CDP endpoint there. The launcher throws a clear `SevereServiceError` in native mode. | +| multiremote / parallel workers | ❌ blocked — CEF can't isolate ≥2 instances (single-instance only). | +| `switchWindow` / `listWindows` (multi-window) | ⚠️ implemented but unreliable, even on macOS (2-window global-context race). | +| `triggerDeeplink` (macOS) | ⚠️ unreliable — no open-url routing to the spawned instance. | +| single-window apps | ⚠️ a lone CEF window doesn't reliably appear in `/json`, so the bridge can intermittently find no target to attach to. Opening a second window stabilises target exposure (the test fixtures do this, staggered behind the first window's `dom-ready`). | +| `emitEvent` | deferred — the Bun event bus isn't CDP-reachable. | + +As each upstream fix lands, the corresponding platform/feature is re-enabled and +the service advances toward `1.0`. + +## License + +MIT diff --git a/packages/electrobun-service/package.json b/packages/electrobun-service/package.json new file mode 100644 index 000000000..9644fa984 --- /dev/null +++ b/packages/electrobun-service/package.json @@ -0,0 +1,100 @@ +{ + "name": "@wdio/electrobun-service", + "version": "0.1.0-next.0", + "description": "WebdriverIO service for testing Electrobun desktop applications", + "author": "WebdriverIO Community", + "homepage": "https://github.com/webdriverio/desktop-mobile/tree/main/packages/electrobun-service", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + }, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": [ + { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + }, + "./dist/cjs/index.js" + ] + }, + "files": [ + "dist", + "docs", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsx ../../scripts/build-package.ts", + "build:watch": "tsx ../../scripts/build-package.ts --watch", + "clean": "shx rm -rf dist", + "clean:dist": "shx rm -rf dist", + "dev": "pnpm run build:watch", + "lint": "biome check --linter-enabled=true --formatter-enabled=false src/", + "lint:fix": "biome check --write --linter-enabled=true --formatter-enabled=false src/", + "test": "vitest run", + "test:unit": "vitest run --config vitest.config.ts", + "test:integration": "vitest run --config vitest.integration.config.ts", + "test:integration:watch": "vitest --config vitest.integration.config.ts", + "test:coverage": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@wdio/electrobun-cdp-bridge": "workspace:*", + "@wdio/globals": "catalog:default", + "@wdio/logger": "catalog:default", + "@wdio/native-core": "workspace:*", + "@wdio/native-spy": "workspace:*", + "@wdio/native-types": "workspace:*", + "@wdio/native-utils": "workspace:*", + "@wdio/spec-reporter": "catalog:default", + "@wdio/types": "catalog:default", + "debug": "^4.4.3", + "get-port": "^7.1.0", + "tslib": "^2.8.1", + "webdriverio": "catalog:default" + }, + "devDependencies": { + "@types/debug": "^4.1.12", + "@types/node": "^25.9.1", + "@vitest/coverage-v8": "^4.1.7", + "shx": "^0.4.0", + "tsx": "^4.22.3", + "typescript": "^5.9.3", + "vitest": "^4.1.7" + }, + "peerDependencies": { + "webdriverio": "^9.0.0" + }, + "peerDependenciesMeta": { + "webdriverio": { + "optional": false + } + }, + "keywords": [ + "webdriverio", + "wdio", + "wdio-service", + "electrobun", + "testing", + "e2e", + "desktop" + ], + "repository": { + "type": "git", + "url": "https://github.com/webdriverio/desktop-mobile.git", + "directory": "packages/electrobun-service" + }, + "bugs": { + "url": "https://github.com/webdriverio/desktop-mobile/issues" + } +} diff --git a/packages/electrobun-service/src/commands/allMocks.ts b/packages/electrobun-service/src/commands/allMocks.ts new file mode 100644 index 000000000..0e8cf9d8d --- /dev/null +++ b/packages/electrobun-service/src/commands/allMocks.ts @@ -0,0 +1,72 @@ +// Mock-lifecycle helpers — clear/reset/restore across every mock created against +// one bridge's store. Each is filterable by `prefix` (a target-path prefix) so a +// multi-suite run can isolate e.g. all 'api.*' mocks. isMockFunction is a +// structural check used by callers to branch on mock-vs-plain functions. + +import type { ElectrobunMock } from '@wdio/native-types'; +import { createLogger } from '@wdio/native-utils'; + +import { SERVICE_NAME } from '../constants.js'; +import type { ElectrobunMockStore } from '../mockStore.js'; + +const log = createLogger(SERVICE_NAME, 'mock'); + +function matchesPrefix(target: string, prefix?: string): boolean { + if (!prefix) { + return true; + } + // Namespace-aware: `prefix` matches the target only at a dot boundary, so 'api' (or + // 'api.') matches 'api' and 'api.fetchData' but NOT sibling namespaces like + // 'api2.fetchData' / 'apiAuth.x' — a bare startsWith would over-match, so + // clearAllMocks('api') would silently clear 'api2.*' too. Normalise a trailing dot so + // both the 'api' and 'api.' forms behave identically. + const base = prefix.endsWith('.') ? prefix.slice(0, -1) : prefix; + return target === base || target.startsWith(`${base}.`); +} + +async function forEachMock( + store: ElectrobunMockStore, + prefix: string | undefined, + fn: (mock: ElectrobunMock) => Promise, +): Promise { + // Snapshot first: mockRestore mutates the store mid-iteration. + for (const [target, mock] of store.getMocks()) { + if (matchesPrefix(target, prefix)) { + // Best-effort per entry (matching closeBridges/stopElectrobunApp teardown): a single + // mock's CDP call failing — e.g. the connection dropped or the inner recorder is gone + // — must not abort the bulk op and leave the remaining mocks installed/unsynced. + try { + await fn(mock); + } catch (error) { + log.warn(`bulk mock op failed for '${target}', continuing: ${(error as Error).message}`); + } + } + } +} + +export async function clearAllMocks(store: ElectrobunMockStore, prefix?: string): Promise { + await forEachMock(store, prefix, (mock) => mock.mockClear()); +} + +export async function resetAllMocks(store: ElectrobunMockStore, prefix?: string): Promise { + await forEachMock(store, prefix, (mock) => mock.mockReset()); +} + +export async function restoreAllMocks(store: ElectrobunMockStore, prefix?: string): Promise { + await forEachMock(store, prefix, (mock) => mock.mockRestore()); +} + +/** + * True iff `candidate` is an ElectrobunMock — checked structurally via the + * `__isElectrobunMock: true` flag set in mock.ts. A bare target-path string is + * resolved against the store so `isMockFunction('api.fetchData')` works too. + */ +export function isMockFunction(candidate: unknown, store: ElectrobunMockStore): boolean { + if (typeof candidate === 'string') { + return store.getMock(candidate) !== undefined; + } + if (candidate == null || typeof candidate !== 'function') { + return false; + } + return (candidate as { __isElectrobunMock?: unknown }).__isElectrobunMock === true; +} diff --git a/packages/electrobun-service/src/commands/execute.ts b/packages/electrobun-service/src/commands/execute.ts new file mode 100644 index 000000000..2a86a9b52 --- /dev/null +++ b/packages/electrobun-service/src/commands/execute.ts @@ -0,0 +1,134 @@ +// browser.electrobun.execute implementation. +// +// Electrobun is CDP-attach: there is no WebDriver executeScript channel for the +// app's webview, so we run the user's code directly in the active CEF content +// target via the CdpBridge's `Runtime.evaluate`. A string-built IIFE is built +// from the user's function source (no Electron-style recast machinery needed for +// MVP), with args inlined as JSON literals. The first callback arg is the +// in-webview surface `window.__WDIO_ELECTROBUN__` (may be `{}` for MVP). +// +// Invariant: this never issues Page.navigate — the bridge only enables Runtime +// on the live target (see @wdio/electrobun-cdp-bridge). + +import type { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import type { ElectrobunAPIs } from '@wdio/native-types'; +import { hasSemicolonOutsideQuotes } from '@wdio/native-utils'; + +interface RemoteObject { + type?: string; + value?: unknown; + description?: string; +} + +interface EvaluateResponse { + result?: RemoteObject; + exceptionDetails?: { + text?: string; + exception?: { description?: string; value?: unknown }; + }; +} + +/** + * Serialise a value to a JS literal for inlining into a script source, rejecting + * anything `JSON.stringify` would silently drop or choke on. Shared by `execute` + * (user args) and the mock layer (impl/return values pushed into the page). + * + * @param context - caller label used in the error message (e.g. `browser.electrobun.execute`). + * @param index - optional positional index appended to the error message. + */ +export function jsonLiteral(value: unknown, context: string, index?: number): string { + const at = index === undefined ? '' : ` at index ${index}`; + // JSON.stringify(fn) / JSON.stringify(symbol) returns `undefined` rather than + // throwing, which would silently drop the value — reject those up front. + if (typeof value === 'function' || typeof value === 'symbol') { + throw new Error( + `${context}${at} is not JSON-serialisable ` + + `(a ${typeof value} cannot be inlined into the script source; JSON.stringify would silently drop it).`, + ); + } + try { + // JSON.stringify leaves U+2028/U+2029 raw (legal JSON since ES2019); escape + // them for the JS-source inlining context — same treatment as pathLiteral. + return (JSON.stringify(value) ?? 'undefined').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); + } catch (err) { + throw new Error( + `${context}${at} is not JSON-serialisable ` + + '(values are inlined into the script source via JSON.stringify). This typically means the ' + + `value contains a circular reference, a BigInt, or a function. Underlying error: ${(err as Error).message}`, + ); + } +} + +function inlineArgs(args: unknown[]): string { + return args.map((arg, index) => jsonLiteral(arg, 'browser.electrobun.execute argument', index)).join(', '); +} + +const STATEMENT_KEYWORD = /^(const|let|var|if|for|while|switch|throw|try|do|return)(?=[^\w$]|$)/; + +/** + * Wrap a string script for `Runtime.evaluate`, mirroring `@wdio/electron-service`: + * a statement-style script (leading keyword like `return`/`const`, or multiple + * statements separated by a real `;`) becomes a function body; anything else is + * treated as an expression and `return`ed. This lets callers write `return 42`, + * `const x = …; return x`, or a bare `1 + 2` and get the value back either way — + * matching the convergent execute surface across services. + */ +function wrapStringScript(script: string): string { + const trimmed = script.trim(); + if (STATEMENT_KEYWORD.test(trimmed) || hasSemicolonOutsideQuotes(trimmed)) { + return `(async function () { ${script} })()`; + } + return `(async function () { return (${script}); })()`; +} + +/** + * Evaluate a raw JS expression in the active CEF content target and return its + * (returned-by-value) result. Centralises `Runtime.evaluate` + exception + * handling for both `execute` and the mock layer. Never issues Page.navigate. + */ +export async function evaluateInActiveTarget( + bridge: CdpBridge, + expression: string, + context = 'browser.electrobun.execute', +): Promise { + const response = (await bridge.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + })) as EvaluateResponse; + + if (response.exceptionDetails) { + const detail = + response.exceptionDetails.exception?.description ?? response.exceptionDetails.text ?? 'Unknown evaluation error'; + throw new Error(`${context} failed: ${detail}`); + } + + return response.result?.value as ReturnValue; +} + +/** + * Run a user function (or raw expression string) in the active Electrobun content + * target and return its (JSON-serialisable) result. + * + * Function form: the source is wrapped in an async IIFE that passes + * `window.__WDIO_ELECTROBUN__` (defaulting to `{}`) as the first arg, then the + * inlined user args. String form: wrapped via `wrapStringScript` so both a bare + * expression (`1 + 2`) and a statement body (`return 42`, `const x = …; return x`) + * return their value — matching the convergent execute surface. + */ +export async function execute( + bridge: CdpBridge, + script: string | ((eb: ElectrobunAPIs, ...args: InnerArguments) => ReturnValue), + ...args: InnerArguments +): Promise { + const expression = + typeof script === 'function' + ? `(async () => { + const userFn = (${script.toString()}); + const eb = window.__WDIO_ELECTROBUN__ || {}; + return await userFn(eb, ${inlineArgs(args)}); + })()` + : wrapStringScript(script); + + return evaluateInActiveTarget(bridge, expression); +} diff --git a/packages/electrobun-service/src/commands/mock.ts b/packages/electrobun-service/src/commands/mock.ts new file mode 100644 index 000000000..14282c0ed --- /dev/null +++ b/packages/electrobun-service/src/commands/mock.ts @@ -0,0 +1,11 @@ +// browser.electrobun.mock(target) — public entry point. + +import type { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import type { ElectrobunMock } from '@wdio/native-types'; + +import { createMock } from '../mock.js'; +import type { ElectrobunMockStore } from '../mockStore.js'; + +export function mock(target: string, bridge: CdpBridge, store: ElectrobunMockStore): Promise { + return createMock(target, bridge, store); +} diff --git a/packages/electrobun-service/src/commands/triggerDeeplink.ts b/packages/electrobun-service/src/commands/triggerDeeplink.ts new file mode 100644 index 000000000..05236a472 --- /dev/null +++ b/packages/electrobun-service/src/commands/triggerDeeplink.ts @@ -0,0 +1,43 @@ +// browser.electrobun.triggerDeeplink implementation. +// +// Fires the OS-native protocol handler so the Electrobun app's registered +// `open-url` handler receives the URL — the same path production sees, and the +// most realistic test of a registered URI scheme (no IPC mocking). +// +// macOS-only in 0.x: Electrobun registers `app.urlSchemes` via the generated +// Info.plist `CFBundleURLTypes`, and `open ` reaches the running app's +// open-url handler. Windows/Linux deeplink support isn't available upstream yet +// (RESEARCH_FINDINGS §6), so this rejects there with a documented-gap error. + +import { executeDeeplinkCommand, getPlatformCommand, validateDeeplinkUrl } from '@wdio/native-core'; +import { createLogger } from '@wdio/native-utils'; + +import { SERVICE_NAME } from '../constants.js'; +import { deeplinkUnsupportedOnPlatform } from '../errors.js'; + +const log = createLogger(SERVICE_NAME, 'triggerDeeplink'); + +/** + * Trigger a deeplink to the Electrobun app by spawning the OS-native protocol + * handler (macOS `open `). + * + * @param url - The deeplink URL (e.g. `wdio-electrobun://open?path=/test`). Must + * use a custom scheme — `http`, `https`, and `file` are rejected. + * @throws when the URL is malformed/disallowed, or on a non-macOS platform. + * + * @example + * ```ts + * await browser.electrobun.triggerDeeplink('wdio-electrobun://open?file=test.txt'); + * ``` + */ +export async function triggerDeeplink(url: string): Promise { + if (process.platform !== 'darwin') { + throw deeplinkUnsupportedOnPlatform(); + } + const validated = validateDeeplinkUrl(url); + log.debug(`triggering deeplink ${validated}`); + + const { command, args } = getPlatformCommand(validated, process.platform); + await executeDeeplinkCommand(command, args); + log.debug(`deeplink dispatched: ${command} ${args.join(' ')}`); +} diff --git a/packages/electrobun-service/src/constants.ts b/packages/electrobun-service/src/constants.ts new file mode 100644 index 000000000..323ebf5df --- /dev/null +++ b/packages/electrobun-service/src/constants.ts @@ -0,0 +1,22 @@ +/** Logger namespace for this service (`createLogger(SERVICE_NAME, '')`). */ +export const SERVICE_NAME = 'electrobun-service'; + +/** WDIO capability key carrying per-capability service options. */ +export const CUSTOM_CAPABILITY_NAME = 'wdio:electrobunServiceOptions'; + +/** + * CEF's documented default remote-debugging port and the floor of its auto-scan + * range [9222, 9232] (used only when an app pins no port). The service does not + * rely on this — it allocates a port and writes it into each worker's build.json. + */ +export const DEFAULT_REMOTE_DEBUGGING_PORT = 9222; + +/** + * Anchor for ports the launcher allocates (via PortManager) and writes into each + * worker's bundle build.json. Kept clear of CEF's [9222, 9232] auto-scan range so + * an allocated port can't collide with one CEF picks for an un-pinned app. + */ +export const DEFAULT_DEBUG_PORT_BASE = 9333; + +/** Default label for the first/primary content webview target. */ +export const DEFAULT_WINDOW_LABEL = 'main'; diff --git a/packages/electrobun-service/src/electrobunConfig.ts b/packages/electrobun-service/src/electrobunConfig.ts new file mode 100644 index 000000000..4357f1e39 --- /dev/null +++ b/packages/electrobun-service/src/electrobunConfig.ts @@ -0,0 +1,330 @@ +// Locate and verify a built CEF Electrobun app bundle, and read/write the CEF +// remote-debugging port the launcher pins into the bundle. +// +// Electrobun reads the CDP port EXCLUSIVELY from the bundle's +// `Contents/Resources/build.json` `chromiumFlags["remote-debugging-port"]` at +// runtime (a `--remote-debugging-port` launch arg does NOT reach CEF — see the +// Phase 0 RESEARCH_FINDINGS). So to control the port the launcher writes it into +// build.json. CEF's [9222, 9232] auto-scan is only a fallback when unset. +// +// macOS is the validated platform. Windows/Linux bundle layout is unverified, so +// resolution + the CEF check there are best-effort and guarded by existence +// checks rather than hard-failing on a missing framework — see the per-function +// TODOs. E2E validation there is blocked on the upstream CEF fixes (#320). + +import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; + +import { createLogger } from '@wdio/native-utils'; + +import { SERVICE_NAME } from './constants.js'; +import { cefRendererRequired, SevereServiceError } from './errors.js'; + +const log = createLogger(SERVICE_NAME, 'config'); + +const REMOTE_DEBUGGING_FLAG = 'remote-debugging-port'; +const USER_DATA_DIR_FLAG = 'user-data-dir'; + +/** Parsed shape of a bundle's `build.json` (only the keys this service reads). */ +export interface BuildJson { + identifier?: string; + name?: string; + /** CEF/Chromium command-line flags Electrobun forwards at launch. Values are strings. */ + chromiumFlags?: Record; + /** Renderer selection, when the bundle records it (`'cef'` enables CDP). */ + renderer?: string; + defaultRenderer?: string; + [key: string]: unknown; +} + +/** Resolved locations for a built Electrobun app, derived from the binary/bundle path. */ +export interface ResolvedElectrobunApp { + /** The launcher executable to spawn. */ + binaryPath: string; + /** The `.app` bundle dir on macOS; the binary's parent dir elsewhere. */ + bundlePath: string; + /** Directory holding `build.json` (`Contents/Resources` on macOS; binary dir elsewhere). */ + resourcesDir: string; + /** Absolute path to `build.json`. */ + buildJsonPath: string; + /** App identifier from build.json, when present. */ + identifier?: string; +} + +function pathExists(p: string): boolean { + return existsSync(p); +} + +function isDirectory(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +/** + * Resolve the on-disk layout of a built Electrobun app from an explicit path. + * + * Requires an explicit `appBinaryPath`: this service does NOT guess/auto-detect a + * bundle (the wrong bundle silently attaching to the wrong CDP port is far worse + * than a clear "set appBinaryPath" error). On macOS the path may point at either + * the `.app` directory or the inner `Contents/MacOS/` binary. + * + * @throws SevereServiceError when `appBinaryPath` is missing or does not exist. + */ +export function resolveElectrobunApp( + appBinaryPath: string | undefined, + platform: NodeJS.Platform = process.platform, +): ResolvedElectrobunApp { + if (!appBinaryPath) { + throw new SevereServiceError( + '@wdio/electrobun-service requires an explicit appBinaryPath in native mode. ' + + 'Set it in wdio:electrobunServiceOptions (or the service-level options) — point it at the ' + + 'built app (on macOS, the `.app` bundle or its inner `Contents/MacOS/` binary). ' + + 'Auto-detection is intentionally not attempted: attaching to the wrong bundle would silently ' + + 'drive the wrong app over CDP.', + ); + } + + if (!pathExists(appBinaryPath)) { + throw new SevereServiceError( + `Electrobun app path does not exist: ${appBinaryPath}. ` + + 'Build the app first and point appBinaryPath at the resulting bundle/binary.', + ); + } + + if (platform === 'darwin') { + return resolveMacosApp(appBinaryPath); + } + + // Windows/Linux: electrobun emits `/bin/launcher[.exe]` with build.json under + // `/Resources/` (verified from the CI build artifacts). When the binary sits in + // a `bin/` dir, the bundle root is its grandparent and Resources/build.json hang off + // that; otherwise (a flat/custom layout) fall back to a sibling build.json. + const binaryPath = appBinaryPath; + const binDir = dirname(binaryPath); + let bundlePath: string; + let resourcesDir: string; + if (basename(binDir) === 'bin') { + bundlePath = dirname(binDir); + resourcesDir = join(bundlePath, 'Resources'); + } else { + bundlePath = binDir; + resourcesDir = binDir; + } + const buildJsonPath = join(resourcesDir, 'build.json'); + const identifier = readBuildJson(buildJsonPath)?.identifier; + return { binaryPath, bundlePath, resourcesDir, buildJsonPath, identifier }; +} + +function resolveMacosApp(appPath: string): ResolvedElectrobunApp { + const bundlePath = resolveMacosBundle(appPath); + const resourcesDir = join(bundlePath, 'Contents', 'Resources'); + const buildJsonPath = join(resourcesDir, 'build.json'); + const binaryPath = resolveMacosBinary(bundlePath, appPath); + const identifier = readBuildJson(buildJsonPath)?.identifier; + + return { binaryPath, bundlePath, resourcesDir, buildJsonPath, identifier }; +} + +function resolveMacosBundle(appPath: string): string { + // Accept either the `.app` directory or the inner binary. + if (appPath.endsWith('.app') && isDirectory(appPath)) { + return appPath; + } + if (isDirectory(appPath) && pathExists(join(appPath, 'Contents'))) { + return appPath; + } + + // Treat as the inner binary: `.app/Contents/MacOS/`. + const macosDir = dirname(appPath); + const contentsDir = dirname(macosDir); + const bundleRoot = dirname(contentsDir); + if (bundleRoot.endsWith('.app')) { + return bundleRoot; + } + // Non-standard layout: fall back to the binary's parent so resolution still + // yields a build.json location for verifyCefRenderer to evaluate. + return dirname(appPath); +} + +function resolveMacosBinary(bundlePath: string, originalPath: string): string { + // If the caller already handed us the inner binary, keep it. + if (!isDirectory(originalPath) && originalPath.includes(join('Contents', 'MacOS'))) { + return originalPath; + } + const macosDir = join(bundlePath, 'Contents', 'MacOS'); + // Candidate exe names, most authoritative first: + // - Info.plist CFBundleExecutable (the launch binary the OS would exec); + // - `launcher` — Electrobun names its launch exe this, NOT after the bundle; + // - `Foo.app` → `Foo` (the generic macOS convention). + // Spawning the `.app` directory itself is not executable (EACCES), so we must + // resolve a real file here. + const candidates = [ + readCFBundleExecutable(join(bundlePath, 'Contents', 'Info.plist')), + 'launcher', + basename(bundlePath).replace(/\.app$/, ''), + ]; + for (const name of candidates) { + if (!name) { + continue; + } + const exe = join(macosDir, name); + if (pathExists(exe) && !isDirectory(exe)) { + return exe; + } + } + // Fall back to the original path (may be the binary itself or a non-standard layout). + return originalPath; +} + +/** Read CFBundleExecutable from an XML Info.plist; undefined for a binary plist / on error. */ +function readCFBundleExecutable(plistPath: string): string | undefined { + try { + const xml = readFileSync(plistPath, 'utf8'); + return xml.match(/CFBundleExecutable<\/key>\s*([^<]+)<\/string>/)?.[1]?.trim(); + } catch { + return undefined; + } +} + +/** + * Confirm the app is built with the CEF renderer (the only renderer that exposes + * a CDP endpoint). On macOS this is true if the CEF `.framework` is bundled under + * `Contents/Frameworks`, OR build.json records a `cef` renderer. On other + * platforms it is best-effort (build.json / sibling CEF lib) and deliberately + * does NOT false-negative when the layout can't be inspected. + * + * @throws cefRendererRequired when CEF is positively absent on macOS. + */ +export function verifyCefRenderer(app: ResolvedElectrobunApp, platform: NodeJS.Platform = process.platform): void { + const buildJson = readBuildJson(app.buildJsonPath); + + if (platform === 'darwin') { + const frameworkPath = join(app.bundlePath, 'Contents', 'Frameworks', 'Chromium Embedded Framework.framework'); + if (pathExists(frameworkPath)) { + log.debug(`CEF framework found: ${frameworkPath}`); + return; + } + if (buildJsonIndicatesCef(buildJson)) { + log.debug('CEF renderer indicated by build.json'); + return; + } + throw cefRendererRequired(platform); + } + + // Windows/Linux: best-effort. Pass if build.json indicates CEF or a sibling CEF + // library is present; otherwise warn (don't hard-fail) since the layout is + // unverified. TODO(#320): confirm the real CEF marker when these platforms unblock. + if (buildJsonIndicatesCef(buildJson)) { + return; + } + if (siblingCefLibraryExists(app, platform)) { + return; + } + log.warn( + `Could not positively confirm the CEF renderer on ${platform} (bundle layout unverified). ` + + 'Proceeding — if CDP attach fails, ensure the app was built with the CEF renderer.', + ); +} + +function buildJsonIndicatesCef(buildJson: BuildJson | undefined): boolean { + if (!buildJson) { + return false; + } + const renderer = (buildJson.renderer ?? buildJson.defaultRenderer ?? '').toString().toLowerCase(); + if (renderer.includes('cef')) { + return true; + } + // A pinned remote-debugging port is a strong CEF signal — only CEF reads it. + return getRemoteDebuggingPort(buildJson) !== undefined; +} + +function siblingCefLibraryExists(app: ResolvedElectrobunApp, platform: NodeJS.Platform): boolean { + const candidates = + platform === 'win32' ? ['libcef.dll', 'cef.dll'] : ['libcef.so', 'libElectrobunCEF.so', 'libNativeWrapper.so']; + return candidates.some((name) => pathExists(join(app.resourcesDir, name)) || pathExists(join(app.bundlePath, name))); +} + +/** + * Read and parse a bundle's `build.json`. Returns `undefined` when the file is + * absent or unparseable (a malformed build.json shouldn't crash resolution; the + * caller decides whether the missing data is fatal). + */ +export function readBuildJson(buildJsonPath: string): BuildJson | undefined { + if (!pathExists(buildJsonPath)) { + return undefined; + } + try { + const raw = readFileSync(buildJsonPath, 'utf8'); + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) { + return undefined; + } + return parsed as BuildJson; + } catch (error) { + log.warn(`Failed to parse build.json at ${buildJsonPath}: ${(error as Error).message}`); + return undefined; + } +} + +/** Read the pinned CEF remote-debugging port from a parsed build.json, if any. */ +export function getRemoteDebuggingPort(buildJson: BuildJson | undefined): number | undefined { + const raw = buildJson?.chromiumFlags?.[REMOTE_DEBUGGING_FLAG]; + if (raw === undefined) { + return undefined; + } + const parsed = Number.parseInt(String(raw), 10); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * Pin a CEF remote-debugging port into a bundle's `build.json` under `chromiumFlags`, + * preserving every other key. CEF reads these flags from build.json (not argv), so this is + * how the launcher fixes the CDP endpoint for each worker's cloned bundle. + * + * `userDataDir` is still supported (written as `--user-data-dir` when provided), but the + * launcher intentionally does NOT pass it: a separate `--user-data-dir` puts CEF's forced + * `persist:default` partition profile OUTSIDE `root_cache_path`, triggering "Cannot create + * profile" (the disproven multiremote approach — see nativeMode.ts for the full note). The + * parameter stays for completeness / future use if upstream relaxes the constraint. + * + * @throws SevereServiceError when build.json is missing/unwritable — without it + * the port can't be pinned and the worker's CDP attach has no fixed endpoint. + */ +export function writeRemoteDebuggingPort(buildJsonPath: string, port: number, userDataDir?: string): void { + const existing = readBuildJson(buildJsonPath); + if (!existing) { + if (!pathExists(buildJsonPath)) { + throw new SevereServiceError( + `Cannot pin the CEF remote-debugging port: build.json not found at ${buildJsonPath}. ` + + 'The Electrobun bundle is missing its Contents/Resources/build.json.', + ); + } + // File present but unparseable — refuse to clobber it. + throw new SevereServiceError( + `Cannot pin the CEF remote-debugging port: build.json at ${buildJsonPath} is not valid JSON.`, + ); + } + + const next: BuildJson = { + ...existing, + chromiumFlags: { + ...existing.chromiumFlags, + [REMOTE_DEBUGGING_FLAG]: String(port), + ...(userDataDir ? { [USER_DATA_DIR_FLAG]: userDataDir } : {}), + }, + }; + + try { + writeFileSync(buildJsonPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + log.debug( + `Pinned remote-debugging-port=${port}${userDataDir ? ` + user-data-dir=${userDataDir}` : ''} into ${buildJsonPath}`, + ); + } catch (error) { + throw new SevereServiceError( + `Failed to write the CEF remote-debugging port into ${buildJsonPath}: ${(error as Error).message}`, + ); + } +} diff --git a/packages/electrobun-service/src/errors.ts b/packages/electrobun-service/src/errors.ts new file mode 100644 index 000000000..ca16630a7 --- /dev/null +++ b/packages/electrobun-service/src/errors.ts @@ -0,0 +1,61 @@ +// Error helpers for the Electrobun service. +// +// `SevereServiceError` (re-exported from webdriverio) tells the WDIO runner the +// failure is non-recoverable and the run should abort — used when a config +// fundamentally can't work, e.g. the app wasn't built with the CEF renderer so +// no CDP endpoint exists to attach to. + +import { SevereServiceError } from 'webdriverio'; + +export { SevereServiceError }; + +/** + * Thrown by the launcher when the Electrobun app under test was not built with + * the CEF renderer. The default WebKit webviews (WKWebView on macOS, WebKitGTK + * on Linux) expose no Chrome DevTools Protocol endpoint, so a CDP-attach service + * cannot drive them — the app must build with `defaultRenderer: 'cef'` / + * `bundleCEF: true` in `electrobun.config.ts`. + */ +export function cefRendererRequired(platform: NodeJS.Platform = process.platform): Error { + return new SevereServiceError( + '@wdio/electrobun-service requires the Electrobun app to be built with the CEF renderer ' + + "(set defaultRenderer: 'cef' / bundleCEF: true for the target OS in electrobun.config.ts). " + + `The default system webview on ${platform} does not expose a Chrome DevTools Protocol ` + + 'endpoint, so the app cannot be automated over CDP. See the @wdio/electrobun-service README ' + + 'for the renderer requirement and the supported-platform matrix.', + ); +} + +/** + * Thrown by the launcher in native mode on Linux/Windows. CEF-rendered Electrobun + * apps are only automatable on macOS in the 0.x line: on Linux/Windows, CEF's + * failed-`persist:default`-profile fallback serves no `/json`, so Chromedriver can + * never attach (an upstream electrobun/CEF limitation, not fixable from the service). + * Fail fast with an actionable message rather than letting the user hit a cryptic + * CDP-attach timeout. Native-renderer (WebView2/WebKitGTK) support that would cover + * these platforms is a planned follow-up — webdriverio/desktop-mobile#317. + */ +export function cefNativeModeMacOnly(platform: NodeJS.Platform = process.platform): Error { + return new SevereServiceError( + '@wdio/electrobun-service can only automate CEF-rendered Electrobun apps on macOS in this ' + + `0.x release (current platform: ${platform}). On Linux and Windows, CEF does not expose a ` + + 'reachable Chrome DevTools Protocol endpoint (an upstream limitation), so the app cannot be ' + + "driven over CDP. Run your Electrobun e2e tests on macOS, or use browser mode (mode: 'browser') " + + 'against a dev server. Native-renderer (WebView2/WebKitGTK) support for Windows/Linux is a ' + + 'planned follow-up — see https://github.com/webdriverio/desktop-mobile/issues/317.', + ); +} + +/** + * Returned (rejected) by `triggerDeeplink` on platforms where Electrobun does + * not yet support custom URL schemes. 0.x supports macOS only (schemes are + * registered via the generated `Info.plist`); Windows/Linux are a documented + * gap pending upstream support. + */ +export function deeplinkUnsupportedOnPlatform(platform: NodeJS.Platform = process.platform): Error { + return new Error( + `triggerDeeplink is only supported on macOS in this release (current platform: ${platform}). ` + + 'Electrobun custom URL schemes are registered via Info.plist on macOS; Windows/Linux deeplink ' + + 'support is pending upstream and is tracked as a known gap.', + ); +} diff --git a/packages/electrobun-service/src/index.ts b/packages/electrobun-service/src/index.ts new file mode 100644 index 000000000..cda8a606b --- /dev/null +++ b/packages/electrobun-service/src/index.ts @@ -0,0 +1,25 @@ +// @wdio/electrobun-service entry point. +// +// - Default export: the worker-side service (registered automatically by the +// WDIO test runner via `services: ['@wdio/electrobun-service']`). +// - Named `launcher` export: the main-process launch service (auto-detected by +// the runner using the standard service convention). +// +// The bare import pulls in @wdio/native-types' ambient module augmentation so +// `browser.electrobun.*` and `wdio:electrobunServiceOptions` are typed for +// consumers. +import '@wdio/native-types'; + +import ElectrobunLaunchService from './launcher.js'; +import ElectrobunWorkerService from './service.js'; + +export { ElectrobunLaunchService as launcher }; +export default ElectrobunWorkerService; + +export { cefRendererRequired, deeplinkUnsupportedOnPlatform, SevereServiceError } from './errors.js'; +export { + cleanup as cleanupWdioSession, + createElectrobunCapabilities, + init as startWdioSession, +} from './session.js'; +export type { ElectrobunCapabilities, ElectrobunServiceGlobalOptions, ElectrobunServiceOptions } from './types.js'; diff --git a/packages/electrobun-service/src/innerRecorder.ts b/packages/electrobun-service/src/innerRecorder.ts new file mode 100644 index 000000000..ce43452f4 --- /dev/null +++ b/packages/electrobun-service/src/innerRecorder.ts @@ -0,0 +1,297 @@ +// Inner-recorder script builders for the Electrobun mock layer. +// +// Electrobun has no enumerable main-process API, so `browser.electrobun.mock` +// targets a *dotted path to a function in the webview global scope* (e.g. +// 'api.fetchData' → window.api.fetchData) — the in-page analogue of Electron's +// browser-mode IPC-channel mock. These builders return JS expression strings +// that the worker evaluates over CDP via `Runtime.evaluate` (see +// commands/execute.ts#evaluateInActiveTarget). They never navigate the page. +// +// All recorders live under a single registry, window.__WDIO_ELECTROBUN_MOCKS__, +// keyed by target path. Each entry preserves the original function so +// mockRestore can put it back exactly where it was. The recorder function itself +// is a vitest-shaped spy whose call/result history is read back one-way into the +// outer mock by buildReadCallDataScript (mirrors the inner spy in +// @wdio/native-spy's WDIO_MOCK_SETUP_SCRIPT). +// +// The string semantics here are exercised end-to-end only against a real CEF app +// (no CEF in unit tests) — unit tests assert the *expressions* we emit, not their +// in-page behaviour. See test/mock.spec.ts. + +import type { InnerMockSetterMethod } from './mockTypes.js'; + +const REGISTRY = 'window.__WDIO_ELECTROBUN_MOCKS__'; + +// A mock target is a dotted path of JS identifiers (`api.fetchData`) — that's all +// the RESOLVE_PATH `.split('.')` walk can address. The VALID_TARGET guard rejects +// anything else (clear errors + defense-in-depth: a target that passes can't carry +// a quote, backslash, newline, or line/paragraph separator). +const VALID_TARGET = /^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$/; + +/** JS-source literal of a validated target path, safe to inline as an object key / string arg. */ +function pathLiteral(target: string): string { + if (!VALID_TARGET.test(target)) { + throw new Error( + `browser.electrobun.mock target ${JSON.stringify(target)} is not a valid dotted property path ` + + `(expected identifiers separated by dots, e.g. 'api.fetchData').`, + ); + } + // The `${key}` interpolations below land in a JS *source* string (Runtime.evaluate), + // not a JSON/data context. JSON.stringify leaves the two chars that terminate a JS + // source-string literal — U+2028 / U+2029 — raw, and doesn't neutralise `` + // for HTML-embedded eval, so finish the escaping. The VALID_TARGET guard already makes + // these no-ops, but this is the escaping CodeQL's js/bad-code-sanitization recognises + // (a regex `.test()` guard alone does not). + return JSON.stringify(target) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029') + .replace(/ 0 ? _implQueue.shift() : _defaultImpl; + if (impl !== null && impl !== undefined && typeof impl === 'object' && impl.__wdioType) { + if (impl.__wdioType === 'resolve') { + _results.push({ type: 'return', value: impl.__wdioVal }); + return Promise.resolve(impl.__wdioVal); + } + _results.push({ type: 'throw', value: impl.__wdioVal }); + return Promise.reject(impl.__wdioVal); + } + if (impl !== undefined) { + try { + var val = impl.apply(this, args); + _results.push({ type: 'return', value: val }); + return val; + } catch (e) { + _results.push({ type: 'throw', value: e }); + throw e; + } + } else if (_defaultRejectedValue !== NOT_SET) { + _results.push({ type: 'throw', value: _defaultRejectedValue }); + return Promise.reject(_defaultRejectedValue); + } else if (_defaultResolvedValue !== NOT_SET) { + _results.push({ type: 'return', value: _defaultResolvedValue }); + return Promise.resolve(_defaultResolvedValue); + } else if (_returnThis) { + _results.push({ type: 'return', value: this }); + return this; + } else if (_defaultReturnValue !== NOT_SET) { + _results.push({ type: 'return', value: _defaultReturnValue }); + return _defaultReturnValue; + } else { + _results.push({ type: 'return', value: undefined }); + return undefined; + } + } + spy.__isWdioSpy = true; + spy.mock = { calls: _calls, results: _results, invocationCallOrder: _invocationCallOrder }; + spy.mockImplementation = function(fn) { + _defaultImpl = fn; _defaultReturnValue = NOT_SET; _defaultResolvedValue = NOT_SET; + _defaultRejectedValue = NOT_SET; _returnThis = false; return spy; + }; + spy.mockImplementationOnce = function(fn) { _implQueue.push(fn); return spy; }; + spy.mockReturnValue = function(v) { + _defaultImpl = undefined; _defaultReturnValue = v; _defaultResolvedValue = NOT_SET; + _defaultRejectedValue = NOT_SET; _returnThis = false; return spy; + }; + spy.mockReturnValueOnce = function(v) { _implQueue.push(function() { return v; }); return spy; }; + spy.mockResolvedValue = function(v) { + _defaultImpl = undefined; _defaultResolvedValue = v; _defaultReturnValue = NOT_SET; + _defaultRejectedValue = NOT_SET; _returnThis = false; return spy; + }; + spy.mockResolvedValueOnce = function(v) { _implQueue.push({ __wdioType: 'resolve', __wdioVal: v }); return spy; }; + spy.mockRejectedValue = function(v) { + _defaultImpl = undefined; _defaultRejectedValue = v; _defaultReturnValue = NOT_SET; + _defaultResolvedValue = NOT_SET; _returnThis = false; return spy; + }; + spy.mockRejectedValueOnce = function(v) { _implQueue.push({ __wdioType: 'reject', __wdioVal: v }); return spy; }; + spy.mockReturnThis = function() { + _returnThis = true; _defaultReturnValue = NOT_SET; _defaultResolvedValue = NOT_SET; + _defaultRejectedValue = NOT_SET; _defaultImpl = undefined; return spy; + }; + spy.mockClear = function() { + _calls.length = 0; _results.length = 0; _invocationCallOrder.length = 0; return spy; + }; + spy.mockReset = function() { + spy.mockClear(); + _implQueue = []; _defaultImpl = undefined; _defaultReturnValue = NOT_SET; + _defaultResolvedValue = NOT_SET; _defaultRejectedValue = NOT_SET; _returnThis = false; return spy; + }; + return spy; + }; + }`; + +/** Walk a dotted path to its `{ parent, key }`, returning undefined if any hop is missing. */ +const RESOLVE_PATH = ` + var __resolve = function(path) { + var parts = path.split('.'); + var parent = window; + for (var i = 0; i < parts.length - 1; i++) { + if (parent == null) { return undefined; } + parent = parent[parts[i]]; + } + if (parent == null) { return undefined; } + return { parent: parent, key: parts[parts.length - 1] }; + };`; + +/** + * Install (idempotently) a recorder over the target function. Preserves the + * original on the registry entry, replaces `parent[key]` with the spy, and + * throws inside the page if the path doesn't resolve to a function. Re-running + * with an existing entry is a no-op (no double-wrap). + */ +export function buildInstallScript(target: string): string { + const key = pathLiteral(target); + return `(function() {${SETUP}${RESOLVE_PATH} + var existing = __ebReg[${key}]; + if (existing) { return; } + var loc = __resolve(${key}); + if (!loc) { + throw new Error('browser.electrobun.mock target ' + ${key} + ' could not be resolved: a parent on the path is undefined.'); + } + var original = loc.parent[loc.key]; + if (typeof original !== 'function') { + throw new Error('browser.electrobun.mock target ' + ${key} + ' is not a function (got ' + typeof original + ').'); + } + var spy = __ebReg.__createSpy(); + __ebReg[${key}] = { spy: spy, original: original, parent: loc.parent, key: loc.key }; + loc.parent[loc.key] = spy; + })()`; +} + +/** Read the recorder's call data back as a JSON-cloned `{ calls, results, invocationCallOrder }`. */ +export function buildReadCallDataScript(target: string): string { + const key = pathLiteral(target); + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (!entry || !entry.spy || !entry.spy.mock) { return { calls: [], results: [], invocationCallOrder: [] }; } + var m = entry.spy.mock; + // Fresh replacer per stringify: serialise Errors to a marker, and guard + // against circular/function values (e.g. mockReturnThis storing a live + // \`this\` that back-references its parent) so update() never throws a + // "Converting circular structure to JSON" out of the page. + var makeSafe = function() { + var seen = []; + return function(_k, v) { + if (v instanceof Error) { return { __wdioError: true, name: v.name, message: v.message, stack: v.stack }; } + if (typeof v === 'function') { return '[Function]'; } + if (v && typeof v === 'object') { + if (seen.indexOf(v) !== -1) { return '[Circular]'; } + seen.push(v); + } + return v; + }; + }; + return { + calls: JSON.parse(JSON.stringify(m.calls || [], makeSafe())), + results: JSON.parse(JSON.stringify(m.results || [], makeSafe())), + invocationCallOrder: JSON.parse(JSON.stringify(m.invocationCallOrder || [])), + }; + })()`; +} + +/** Push a serialised implementation (function source) into the recorder. */ +export function buildSetImplementationScript(target: string, source: string, once = false): string { + const key = pathLiteral(target); + const method = once ? 'mockImplementationOnce' : 'mockImplementation'; + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (entry && entry.spy) { entry.spy.${method}((${source})); } + })()`; +} + +/** + * Push a value-based behaviour into the recorder. `valueLiteral` must already be + * a JS literal (see jsonLiteral / errorLiteral in mock.ts). Errors flagged with + * `__wdioError` are reconstructed into real `Error` objects inside the page. + */ +export function buildSetValueScript(target: string, method: InnerMockSetterMethod, valueLiteral: string): string { + const key = pathLiteral(target); + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (!entry || !entry.spy) { return; } + var _v = ${valueLiteral}; + var arg = _v; + if (_v && typeof _v === 'object' && _v.__wdioError === true) { + arg = new Error(_v.message); + if (_v.name) { arg.name = _v.name; } + if (_v.stack) { arg.stack = _v.stack; } + } + entry.spy.${method}(arg); + })()`; +} + +/** Clear the recorder's call history (mockClear). */ +export function buildClearScript(target: string): string { + const key = pathLiteral(target); + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (entry && entry.spy) { entry.spy.mockClear(); } + })()`; +} + +/** Reset the recorder's implementation and history (mockReset). */ +export function buildResetScript(target: string): string { + const key = pathLiteral(target); + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (entry && entry.spy) { entry.spy.mockReset(); } + })()`; +} + +/** Invoke mockReturnThis on the recorder. */ +export function buildReturnThisScript(target: string): string { + const key = pathLiteral(target); + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (entry && entry.spy) { entry.spy.mockReturnThis(); } + })()`; +} + +/** + * Restore the original function at the target path and drop the registry entry. + * Re-assigns `parent[key]` to the preserved original so production code sees the + * real implementation again. + */ +export function buildRestoreScript(target: string): string { + const key = pathLiteral(target); + return `(function() { + var reg = ${REGISTRY}; + var entry = reg && reg[${key}]; + if (!entry) { return; } + if (entry.parent) { entry.parent[entry.key] = entry.original; } + delete reg[${key}]; + })()`; +} diff --git a/packages/electrobun-service/src/launcher.ts b/packages/electrobun-service/src/launcher.ts new file mode 100644 index 000000000..30f5a23a7 --- /dev/null +++ b/packages/electrobun-service/src/launcher.ts @@ -0,0 +1,253 @@ +import { BaseLauncher, closeLogWriter, isLogWriterInitialized } from '@wdio/native-core'; +import { createLogger } from '@wdio/native-utils'; +import type { Options } from '@wdio/types'; + +import { DEFAULT_DEBUG_PORT_BASE, SERVICE_NAME } from './constants.js'; +import { type ResolvedElectrobunApp, resolveElectrobunApp, verifyCefRenderer } from './electrobunConfig.js'; +import { cefNativeModeMacOnly, SevereServiceError } from './errors.js'; +import { type ElectrobunAppProcess, spawnElectrobunApp, stopElectrobunApp, waitForCdpReady } from './nativeMode.js'; +import { getServiceOptionsFromCapability, mergeServiceOptions } from './serviceConfig.js'; +import type { ElectrobunCapabilities, ElectrobunServiceGlobalOptions, ElectrobunServiceOptions } from './types.js'; + +const log = createLogger(SERVICE_NAME, 'launcher'); + +/** + * Main-process launcher for `@wdio/electrobun-service`. + * + * Electrobun is a CDP-attach framework: the launcher spawns the app binary and + * the worker attaches over CDP through Chromedriver (`debuggerAddress`). It + * extends `BaseLauncher` to reuse `@wdio/native-core`'s port/process/log infra. + * + * 0.x is macOS-only + single-instance (`maxInstances=1`): CEF can't isolate the + * forced `persist:default` profile per worker, so we do NOT redirect the cache root + * (no `CFFIXED_USER_HOME`, no per-worker `--user-data-dir`) — CEF uses its own + * `root_cache_path`. See the implementation plan "Framework gaps". Native-mode flow: + * - `onPrepare`: resolve + CEF-verify each app bundle, force `browserName: 'chrome'`. + * - `onWorkerStart`: allocate a port; spawn the app (the spawn path clones the bundle + * and pins the port into the clone's build.json); wait for CEF to serve `/json`; + * set `goog:chromeOptions.debuggerAddress`. + * - `onComplete`: kill spawned apps + clean temp dirs. + */ +export default class ElectrobunLaunchService extends BaseLauncher { + private browserMode = false; + /** Resolved app bundle per capability index, set in onPrepare for onWorkerStart. */ + private resolvedApps: ResolvedElectrobunApp[] = []; + /** + * Spawned apps keyed by worker cid. Torn down per-spec in onWorkerEnd so apps + * don't accumulate across a run — multiple live CEF instances contend (profile + * creation / resources) even when specs run serially. onComplete sweeps any + * stragglers (e.g. a worker that never reported end). + */ + private spawnedAppsByCid = new Map(); + + constructor( + private options: ElectrobunServiceGlobalOptions, + _capabilities: ElectrobunCapabilities, + _config: Options.Testrunner, + ) { + const basePort = options.remoteDebuggingPort ?? DEFAULT_DEBUG_PORT_BASE; + super({ + basePort, + // Electrobun is CDP-attach: there is no separate native driver process, so + // baseNativePort is nominal. Anchored alongside basePort, clear of CEF's + // [9222, 9232] auto-scan range so PortManager never hands out a port CEF + // might grab for an un-pinned app. + baseNativePort: basePort + 1, + }); + // Don't serialise the full testrunner config/capabilities — they can carry + // credentials (reporter tokens, cloud keys) that shouldn't land in debug logs. + log.debug('ElectrobunLaunchService initialised'); + } + + async onPrepare( + config: Options.Testrunner, + capabilities: ElectrobunCapabilities[] | Record, + ): Promise { + const capsList = normaliseCaps(capabilities); + + // Reject a mixed browser-mode + native-mode capability set: this launcher + // applies one mode to all caps (browser mode forces browserName:'chrome' on + // every cap and skips setup; native mode spawns a binary per cap). Silently + // treating a native cap as browser (or vice-versa) because a sibling cap set + // the other mode would be a confusing footgun — fail fast on a consistent mode. + const modes = capsList.map((cap) => mergeServiceOptions(this.options, getServiceOptionsFromCapability(cap)).mode); + if (modes.some((mode) => mode === 'browser') && modes.some((mode) => mode !== 'browser')) { + throw new SevereServiceError( + 'Mixed browser-mode and native-mode Electrobun capabilities in a single run are not supported. ' + + 'Set `mode` consistently across all capabilities (all "browser", or all native).', + ); + } + + // Detect browser mode even when set on a non-first capability. + const primaryCap = + capsList.find( + (cap) => mergeServiceOptions(this.options, getServiceOptionsFromCapability(cap)).mode === 'browser', + ) ?? capsList[0]; + const mergedOptions = mergeServiceOptions(this.options, getServiceOptionsFromCapability(primaryCap)); + + // Browser mode: skip all binary/CDP setup — the frontend runs against a dev + // server in a plain Chrome session. + if (mergedOptions.mode === 'browser') { + const { devServerUrl } = mergedOptions; + if (!devServerUrl) { + throw new SevereServiceError('devServerUrl is required when mode is "browser"'); + } + try { + new URL(devServerUrl); + } catch { + throw new SevereServiceError(`devServerUrl is not a valid URL: ${devServerUrl}`); + } + for (const cap of capsList) { + (cap as { browserName?: string }).browserName = 'chrome'; + } + this.browserMode = true; + log.info('Browser mode enabled — skipping Electrobun binary/CDP setup'); + return; + } + + // Native mode is macOS-only in the 0.x line: on Linux/Windows, CEF's failed-profile + // fallback serves no /json so Chromedriver can never attach (upstream-blocked, see the + // plan "Framework gaps" / #317). Fail fast here rather than let the user hit a cryptic + // CDP-attach timeout. Browser mode is unaffected — it already returned above. Lift this + // when the WebView2/WebKitGTK native-renderer transports land (#317). + if (process.platform !== 'darwin') { + throw cefNativeModeMacOnly(process.platform); + } + + // CEF can't isolate ≥2 app instances (shared root_cache_path → instance folding; + // upstream-blocked, #320). WDIO's default maxInstances is 100, so this can't be a + // hard error — warn so a run that does schedule parallel workers fails recognisably. + if ((config.maxInstances ?? 1) > 1) { + log.warn( + `maxInstances is ${config.maxInstances}, but Electrobun CEF is single-instance: parallel workers ` + + 'share one CEF cache root and race ("Cannot create profile" / CDP timeouts). Pin maxInstances: 1.', + ); + } + + // Native mode: resolve + CEF-verify each bundle, force chrome capability. The + // app clone + port pinning + spawn happen in onWorkerStart so each worker gets + // a freshly allocated port pinned into its own bundle clone. + this.resolvedApps = []; + for (const cap of capsList) { + const instanceOptions = mergeServiceOptions(this.options, getServiceOptionsFromCapability(cap)); + const app = resolveElectrobunApp(instanceOptions.appBinaryPath); + verifyCefRenderer(app); + this.resolvedApps.push(app); + (cap as { browserName?: string }).browserName = 'chrome'; + } + log.info(`Native mode prepared ${this.resolvedApps.length} Electrobun app(s)`); + } + + async onWorkerStart( + cid: string, + capabilities: ElectrobunCapabilities | ElectrobunCapabilities[] | undefined, + ): Promise { + if (this.browserMode) { + log.debug(`Worker ${cid}: browser mode — skipping app spawn`); + return; + } + if (!capabilities) { + log.warn(`Worker ${cid}: no capabilities provided, skipping spawn`); + return; + } + + const capsList = Array.isArray(capabilities) ? capabilities : [capabilities]; + const workerApps: ElectrobunAppProcess[] = []; + + for (let i = 0; i < capsList.length; i++) { + const cap = capsList[i]; + // The resolved bundle is the source template; each worker (cid) clones it + // and pins its own port inside spawnApp, so one resolved app safely drives + // any number of parallel workers. + const app = this.resolvedApps[i] ?? this.resolvedApps[0]; + if (!app) { + throw new SevereServiceError( + `Worker ${cid}: no resolved Electrobun app for capability ${i}. ` + + 'onPrepare must run before onWorkerStart in native mode.', + ); + } + + const instanceOptions = mergeServiceOptions(this.options, getServiceOptionsFromCapability(cap)); + const port = await this.portManager.allocatePort(this.options.remoteDebuggingPort ?? DEFAULT_DEBUG_PORT_BASE); + + // The allocated port is pinned into the per-worker bundle clone inside + // spawnApp — the CEF port is fixed per bundle (a launch arg does NOT work, + // see RESEARCH_FINDINGS), so the clone is what makes parallel workers safe. + const spawned = this.spawnApp(app, port, instanceOptions, cid); + workerApps.push(spawned); + + const existingChromeOptions = (cap['goog:chromeOptions'] ?? {}) as Record; + cap['goog:chromeOptions'] = { + ...existingChromeOptions, + // 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 → + // the attach (and the /json poll below) fail. The bridge inherits this host + // via parseDebuggerAddress, so it connects on IPv4 too. + debuggerAddress: `127.0.0.1:${port}`, + }; + + // Wait for CEF to actually serve /json with a page target before the worker's + // Chromedriver attaches to debuggerAddress — otherwise it races the (slow on + // Windows) port binding and the session times out. Track the app for teardown + // first so a wait failure still cleans up. + this.spawnedAppsByCid.set(cid, workerApps); + await waitForCdpReady(port); + log.info(`Worker ${cid}: Electrobun app on CDP port ${port} (debuggerAddress set, CDP ready)`); + } + } + + /** Tear down this worker's app(s) when its spec finishes, so they don't accumulate. */ + async onWorkerEnd(cid: string): Promise { + const apps = this.spawnedAppsByCid.get(cid); + if (!apps) { + return; + } + this.spawnedAppsByCid.delete(cid); + for (const app of apps) { + await stopElectrobunApp(app).catch((error: Error) => { + log.warn(`Worker ${cid}: failed to stop Electrobun app: ${error.message}`); + }); + } + } + + // Seam for unit tests to assert spawn wiring without launching a real process. + protected spawnApp( + app: ResolvedElectrobunApp, + port: number, + options: ElectrobunServiceOptions, + instanceId?: string, + ): ElectrobunAppProcess { + return spawnElectrobunApp({ + app, + appArgs: options.appArgs ?? [], + port, + options, + instanceId, + }); + } + + async onComplete(): Promise { + for (const apps of this.spawnedAppsByCid.values()) { + for (const app of apps) { + await stopElectrobunApp(app).catch((error: Error) => { + log.warn(`Failed to stop Electrobun app: ${error.message}`); + }); + } + } + this.spawnedAppsByCid.clear(); + + await this.stopAllDrivers(); + if (isLogWriterInitialized(SERVICE_NAME)) { + await closeLogWriter(SERVICE_NAME); + } + } +} + +function normaliseCaps( + capabilities: ElectrobunCapabilities[] | Record, +): ElectrobunCapabilities[] { + if (Array.isArray(capabilities)) { + return capabilities; + } + return Object.values(capabilities).map((entry) => entry.capabilities); +} diff --git a/packages/electrobun-service/src/mock.ts b/packages/electrobun-service/src/mock.ts new file mode 100644 index 000000000..79711ae27 --- /dev/null +++ b/packages/electrobun-service/src/mock.ts @@ -0,0 +1,260 @@ +// createMock — the workhorse behind browser.electrobun.mock(target). +// +// An Electrobun mock is two cooperating objects (see +// agent-os/standards/global/mock-architecture.md): +// +// 1. The "outer mock" — a vitest-flavoured fn() from @wdio/native-spy that +// lives in the WDIO worker. Tests inspect it via mock.mock.calls/results. +// 2. The "inner recorder" — a spy installed over window. in the CEF +// webview (innerRecorder.ts), driven over CDP Runtime.evaluate. +// +// `target` is a dotted path to a function in the webview global scope +// ('api.fetchData' → window.api.fetchData). This is the in-page analogue of +// browser.dioxus.mock(command) / browser.tauri.mock — Electrobun has no +// enumerable main-process API to mock, so we replace the function in place and +// preserve the original for restore. +// +// User-facing setters (mockReturnValue, mockImplementation, …) push behaviour +// into the inner recorder. update() reads the recorder's call history back and +// syncs it ONE-WAY into the outer mock. clear/reset/restore apply to both sides. + +import type { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import { fn as vitestFn } from '@wdio/native-spy'; +import type { AbstractFn, ElectrobunMock, MockResult } from '@wdio/native-types'; +import { createLogger } from '@wdio/native-utils'; + +import { evaluateInActiveTarget, jsonLiteral } from './commands/execute.js'; +import { SERVICE_NAME } from './constants.js'; +import { + buildClearScript, + buildInstallScript, + buildReadCallDataScript, + buildResetScript, + buildRestoreScript, + buildReturnThisScript, + buildSetImplementationScript, + buildSetValueScript, +} from './innerRecorder.js'; +import type { ElectrobunMockStore } from './mockStore.js'; +import type { InnerMockSetterMethod } from './mockTypes.js'; + +const log = createLogger(SERVICE_NAME, 'mock'); + +interface CallData { + calls: unknown[][]; + results: MockResult[]; + invocationCallOrder: number[]; +} + +/** + * Revive `{ __wdioError: true, message }` markers produced by the in-page + * errorReplacer (innerRecorder.ts#buildReadCallDataScript) back into real Error + * objects. Mirrors the shared sync protocol in @wdio/native-spy/interceptor; + * inlined here because that package only exports it as a type, not at runtime. + */ +function reconstructErrors(value: unknown): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map(reconstructErrors); + } + const obj = value as Record; + if (obj.__wdioError === true) { + const err = new Error(typeof obj.message === 'string' ? obj.message : ''); + if (typeof obj.name === 'string') { + err.name = obj.name; + } + if (typeof obj.stack === 'string') { + err.stack = obj.stack; + } + return err; + } + const out: Record = {}; + for (const key of Object.keys(obj)) { + out[key] = reconstructErrors(obj[key]); + } + return out; +} + +function parseCallData(raw: unknown): CallData { + if (!raw || typeof raw !== 'object') { + return { calls: [], results: [], invocationCallOrder: [] }; + } + const r = raw as Record; + const calls = Array.isArray(r.calls) + ? (r.calls as unknown[][]).map((args) => (Array.isArray(args) ? args.map(reconstructErrors) : args) as unknown[]) + : []; + const results = Array.isArray(r.results) + ? (r.results as MockResult[]).map((res) => ({ type: res.type, value: reconstructErrors(res.value) })) + : []; + return { + calls, + results, + invocationCallOrder: Array.isArray(r.invocationCallOrder) ? (r.invocationCallOrder as number[]) : [], + }; +} + +/** + * Source text of a user implementation function, for evaluation in the page. + * Native and bound functions stringify to `{ [native code] }`, which would land + * in the page as a SyntaxError — reject them up front with an actionable message. + */ +function implSource(implFn: AbstractFn, target: string): string { + const source = implFn.toString(); + if (/\{\s*\[native code\]\s*\}/.test(source)) { + throw new Error( + `browser.electrobun.mock("${target}"): mockImplementation requires a function with serialisable source — ` + + 'native or bound functions stringify to "[native code]" and cannot be evaluated in the webview', + ); + } + // Parse-check with the page-side wrapping `(${source})` (see + // buildSetImplementationScript): method shorthands (`{ fetch(x) { … } }.fetch`) + // stringify without the `function` keyword and would otherwise only fail in the + // webview as an opaque SyntaxError. new Function compiles without executing. + try { + new Function(`return (${source});`); + } catch { + throw new Error( + `browser.electrobun.mock("${target}"): mockImplementation source is not a valid expression — ` + + 'method shorthands lose the `function` keyword when stringified; pass a function expression or arrow function', + ); + } + return source; +} + +/** Serialise a value/error to a JS literal for inlining into a setter script. */ +function valueLiteral(value: unknown, target: string): string { + if (value instanceof Error) { + // Include `stack` so an error pushed via mockRejectedValue arrives with the same shape + // the read-call-data path returns (which includes stack) — no asymmetry for users + // inspecting thrown mock errors. + return JSON.stringify({ __wdioError: true, name: value.name, message: value.message, stack: value.stack }) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); + } + return jsonLiteral(value, `browser.electrobun.mock("${target}") value`); +} + +/** + * Create (and register) a mock over `window.` in the active webview. + * Injects the inner recorder up front, wires the outer mock's lifecycle to the + * bridge, stores it in `store`, and returns it. + */ +export async function createMock( + target: string, + bridge: CdpBridge, + store: ElectrobunMockStore, +): Promise { + log.debug(`[${target}] createMock — installing inner recorder`); + // Threaded into every evaluation so in-page failures read as mock errors, + // not "browser.electrobun.execute failed". + const mockContext = `browser.electrobun.mock("${target}")`; + + const existing = store.getMock(target); + if (existing) { + // Re-mocking the same target returns the existing handle, but still evaluates + // the install script: if the app reloaded the page, the in-page registry was + // wiped and this re-install heals the recorder. The script's idempotence makes + // the call safe (no double-wrap), not redundant. + await evaluateInActiveTarget(bridge, buildInstallScript(target), mockContext); + return existing; + } + + await evaluateInActiveTarget(bridge, buildInstallScript(target), mockContext); + + const outerMock = vitestFn(); + outerMock.mockName(`electrobun.${target}`); + const outerMockClear = outerMock.mockClear.bind(outerMock); + const outerMockReset = outerMock.mockReset.bind(outerMock); + + const mock = outerMock as unknown as ElectrobunMock; + mock.__isElectrobunMock = true; + + const originalMock = outerMock.mock; + + const setValue = async (method: InnerMockSetterMethod, value: unknown): Promise => { + await evaluateInActiveTarget( + bridge, + buildSetValueScript(target, method, valueLiteral(value, target)), + mockContext, + ); + return mock; + }; + + mock.update = async () => { + const raw = await evaluateInActiveTarget(bridge, buildReadCallDataScript(target), mockContext); + const sync = parseCallData(raw); + + (originalMock.calls as unknown[][]).length = 0; + (originalMock.results as { type: string; value: unknown }[]).length = 0; + (originalMock.invocationCallOrder as number[]).length = 0; + for (let i = 0; i < sync.calls.length; i++) { + (originalMock.calls as unknown[][]).push(sync.calls[i]); + (originalMock.results as { type: string; value: unknown }[]).push( + sync.results[i] ?? { type: 'return', value: undefined }, + ); + (originalMock.invocationCallOrder as number[]).push( + sync.invocationCallOrder[i] ?? originalMock.invocationCallOrder.length, + ); + } + return mock; + }; + + mock.mockImplementation = async (implFn: AbstractFn) => { + await evaluateInActiveTarget( + bridge, + buildSetImplementationScript(target, implSource(implFn, target)), + mockContext, + ); + return mock; + }; + + mock.mockImplementationOnce = async (implFn: AbstractFn) => { + await evaluateInActiveTarget( + bridge, + buildSetImplementationScript(target, implSource(implFn, target), true), + mockContext, + ); + return mock; + }; + + mock.mockReturnValue = (value: unknown) => setValue('mockReturnValue', value); + mock.mockReturnValueOnce = (value: unknown) => setValue('mockReturnValueOnce', value); + mock.mockResolvedValue = (value: unknown) => setValue('mockResolvedValue', value); + mock.mockResolvedValueOnce = (value: unknown) => setValue('mockResolvedValueOnce', value); + mock.mockRejectedValue = (value: unknown) => setValue('mockRejectedValue', value); + mock.mockRejectedValueOnce = (value: unknown) => setValue('mockRejectedValueOnce', value); + + mock.mockReturnThis = async () => { + await evaluateInActiveTarget(bridge, buildReturnThisScript(target), mockContext); + return mock; + }; + + mock.mockClear = async () => { + await evaluateInActiveTarget(bridge, buildClearScript(target), mockContext); + outerMockClear(); + return mock; + }; + + mock.mockReset = async () => { + const currentName = outerMock.getMockName(); + await evaluateInActiveTarget(bridge, buildResetScript(target), mockContext); + outerMockReset(); + outerMock.mockName(currentName); + return mock; + }; + + mock.mockRestore = async () => { + await evaluateInActiveTarget(bridge, buildRestoreScript(target), mockContext); + // Match vitest semantics: restore fully resets the outer spy (history + + // implementation), not just clears its call history. + outerMockReset(); + store.deleteMock(target); + return mock; + }; + + store.setMock(target, mock); + log.debug(`[${target}] mock ready`); + return mock; +} diff --git a/packages/electrobun-service/src/mockStore.ts b/packages/electrobun-service/src/mockStore.ts new file mode 100644 index 000000000..a0f19618c --- /dev/null +++ b/packages/electrobun-service/src/mockStore.ts @@ -0,0 +1,31 @@ +// Per-installed-instance registry of every ElectrobunMock created against one +// bridge. Backs clearAllMocks / resetAllMocks / restoreAllMocks (filterable by +// prefix) and isMockFunction. NOT a singleton: each attached browser/bridge gets +// its own store so multiremote instances don't cross-contaminate. + +import type { ElectrobunMock } from '@wdio/native-types'; + +export class ElectrobunMockStore { + #mocks = new Map(); + + setMock(target: string, mock: ElectrobunMock): ElectrobunMock { + this.#mocks.set(target, mock); + return mock; + } + + getMock(target: string): ElectrobunMock | undefined { + return this.#mocks.get(target); + } + + getMocks(): Array<[string, ElectrobunMock]> { + return Array.from(this.#mocks.entries()); + } + + deleteMock(target: string): boolean { + return this.#mocks.delete(target); + } + + clear(): void { + this.#mocks.clear(); + } +} diff --git a/packages/electrobun-service/src/mockTypes.ts b/packages/electrobun-service/src/mockTypes.ts new file mode 100644 index 000000000..c59648585 --- /dev/null +++ b/packages/electrobun-service/src/mockTypes.ts @@ -0,0 +1,11 @@ +// Internal mock-layer types for @wdio/electrobun-service. Kept local (not in +// @wdio/native-types) because they only describe the inner-recorder transport, +// not the public browser.electrobun.* surface. + +export type InnerMockSetterMethod = + | 'mockReturnValue' + | 'mockReturnValueOnce' + | 'mockResolvedValue' + | 'mockResolvedValueOnce' + | 'mockRejectedValue' + | 'mockRejectedValueOnce'; diff --git a/packages/electrobun-service/src/nativeMode.ts b/packages/electrobun-service/src/nativeMode.ts new file mode 100644 index 000000000..038a2fff6 --- /dev/null +++ b/packages/electrobun-service/src/nativeMode.ts @@ -0,0 +1,288 @@ +// Native-mode process management for the Electrobun launcher. +// +// Electrobun is CDP-attach: the launcher spawns the built app binary, CEF binds +// the port pinned in build.json, and the worker's CdpBridge attaches over CDP. +// This module owns the per-worker bundle clone, the spawn + backend log capture, +// and the teardown that kills the process and removes the clone. +// +// Each worker gets a private bundle clone because CEF reads chromiumFlags +// (remote-debugging-port) ONLY from the bundle's build.json, not a launch arg — so a +// worker needs its own bundle copy to pin its own port. We clone here and write the +// port into the clone, never mutating the user's shared bundle. We deliberately do +// NOT pin a separate --user-data-dir (see spawnElectrobunApp): CEF's own +// root_cache_path is the user-data-dir, which keeps the forced persist:default +// partition profile creatable. That makes instances share root_cache_path, so this is +// single-instance only (maxInstances=1); multiremote stays blocked pending an upstream +// CEF fix (see the agent-os plan "Framework gaps"). +// +// E2E-validation gap: clone/spawn/teardown can only be exercised against a real +// built CEF bundle (none in unit tests). Unit tests mock node:child_process / +// node:fs at the @wdio/native-core + node boundary; the live path is E2E-only. + +import { type ChildProcess, execFileSync, spawn } from 'node:child_process'; +import { cpSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, relative } from 'node:path'; +import type { Interface as ReadlineInterface } from 'node:readline'; + +import { createLogCapture } from '@wdio/native-core'; +import { createLogger } from '@wdio/native-utils'; + +import { SERVICE_NAME } from './constants.js'; +import { type ResolvedElectrobunApp, writeRemoteDebuggingPort } from './electrobunConfig.js'; +import { SevereServiceError } from './errors.js'; +import type { ElectrobunServiceOptions } from './types.js'; + +const log = createLogger(SERVICE_NAME, 'launcher'); + +const SIGKILL_GRACE_MS = 5_000; +const SIGKILL_REAP_MS = 2_000; + +export interface ElectrobunAppProcess { + proc: ChildProcess; + /** Temp dirs to remove on teardown (the bundle-clone parent). */ + cleanupDirs: string[]; + /** Allocated CEF remote-debugging port (pinned into the cloned bundle's build.json). */ + port: number; + logHandlers: ReadlineInterface[]; +} + +export interface SpawnElectrobunAppParams { + app: ResolvedElectrobunApp; + appArgs: string[]; + port: number; + options: ElectrobunServiceOptions; + instanceId?: string; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Clone an app bundle into a fresh temp parent so each worker can pin its own CEF + * remote-debugging port without mutating the user's shared bundle. + * + * On macOS this uses `cp -Rc` (APFS clonefile) so the ~125MB CEF framework is + * copy-on-write rather than duplicated — near-instant. On a non-APFS volume `cp + * -Rc` fails; we fall back to a recursive `cpSync`. Non-darwin platforms always + * take the `cpSync` path. + */ +export function cloneAppBundle(bundlePath: string): { cloneParentDir: string; clonedBundlePath: string } { + const cloneParentDir = mkdtempSync(join(tmpdir(), 'wdio-electrobun-bundle-')); + const clonedBundlePath = join(cloneParentDir, basename(bundlePath)); + + try { + if (process.platform === 'darwin') { + try { + execFileSync('cp', ['-Rc', bundlePath, clonedBundlePath]); + return { cloneParentDir, clonedBundlePath }; + } catch (error) { + // Usually a non-APFS volume (clonefile unsupported), but cp can fail for any + // copy reason (disk full, permissions) — fall back to cpSync either way and + // surface the underlying message rather than asserting a cause. + log.debug(`cp -Rc failed for ${bundlePath}, falling back to recursive copy: ${(error as Error).message}`); + // `cp -Rc` may have written a partial tree before failing; clear it so the cpSync + // fallback starts clean rather than merging onto a half-copied bundle. + rmSync(clonedBundlePath, { recursive: true, force: true }); + } + } + + cpSync(bundlePath, clonedBundlePath, { recursive: true }); + return { cloneParentDir, clonedBundlePath }; + } catch (error) { + // The copy failed (e.g. cpSync threw) — remove the empty temp parent mkdtempSync + // created so it doesn't leak, then rethrow. + rmSync(cloneParentDir, { recursive: true, force: true }); + throw error; + } +} + +/** + * Spawn a built Electrobun app for native-mode CDP attach. + * + * Clones the resolved bundle into a per-worker temp dir and pins ONLY `port` into the + * clone's build.json (CEF reads chromiumFlags there, not argv) — deliberately NOT a + * `--user-data-dir`, so CEF's own root_cache_path stays the user-data-dir and the forced + * persist:default profile creates cleanly (see the inline note below; this is what makes + * the service single-instance / `maxInstances=1`). Spawns the CLONED binary, and (when log + * capture is enabled) wires stdout/stderr through `createLogCapture`. Returns the process + * handle plus the temp dirs so the launcher can tear them all down. + */ +export function spawnElectrobunApp(params: SpawnElectrobunAppParams): ElectrobunAppProcess { + const { app, appArgs, port, options, instanceId } = params; + + if (!app.buildJsonPath) { + throw new SevereServiceError( + `Cannot pin the CEF remote-debugging port: the resolved Electrobun app has no build.json path ` + + `(bundle: ${app.bundlePath}). The port is read from build.json, not a launch arg, so a worker ` + + 'cannot get an isolated port without one.', + ); + } + + const { cloneParentDir, clonedBundlePath } = cloneAppBundle(app.bundlePath); + // Rebase the binary + build.json onto the clone. join+relative, not a substring + // replace — a stray trailing separator would make replace() a silent no-op and + // spawn the user's UNCLONED bundle (whose build.json has the wrong port). + const clonedBinaryPath = join(clonedBundlePath, relative(app.bundlePath, app.binaryPath)); + const clonedBuildJsonPath = join(clonedBundlePath, relative(app.bundlePath, app.buildJsonPath)); + + // The clone isn't tracked for teardown until we return the handle, so remove it + // here if pinning the port throws (it's the large, CEF-bearing temp dir). + // + // Pin ONLY the port — do NOT inject a separate --user-data-dir. CEF then uses its + // own root_cache_path (~/Library/Application Support | ~/.cache | %LOCALAPPDATA% + // per OS) as the Chrome user-data-dir, so the persist:default partition profile + // (at /partitions/default, forced by BrowserWindow) lands INSIDE + // the user-data-dir and creates cleanly. A /tmp --user-data-dir left that partition + // OUTSIDE the profile dir → "Cannot create profile" → a racy global-context fallback + // that was the cross-OS e2e blocker (recoverable on macOS, fatal on Linux/Windows). + // Trade-off: instances now share root_cache_path, so this is single-instance only + // (maxInstances=1) — multiremote stays blocked pending an upstream CEF fix. + try { + writeRemoteDebuggingPort(clonedBuildJsonPath, port); + } catch (error) { + rmSync(cloneParentDir, { recursive: true, force: true }); + throw error; + } + + const env: NodeJS.ProcessEnv = { + ...process.env, + ...options.env, + }; + + // Linux CI runners are headless and CEF is a GUI process that needs an X display + // ("Failed to open X11 display" otherwise → no browser → no /json). WDIO's autoXvfb + // covers the worker process, not this launcher-spawned app, so run the app under + // `xvfb-run -a` (a throwaway X server) on Linux. macOS/Windows runners have a real + // display, so spawn the binary directly there. Unreachable in 0.x (the launcher's + // macOS guard throws first) — kept for the Linux re-fold (#320). + const useXvfb = process.platform === 'linux'; + const command = useXvfb ? 'xvfb-run' : clonedBinaryPath; + const spawnArgs = useXvfb ? ['-a', clonedBinaryPath, ...appArgs] : appArgs; + + log.info(`Spawning Electrobun app: ${clonedBinaryPath} ${appArgs.join(' ')} (CDP port ${port})`); + const proc = spawn(command, spawnArgs, { + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }); + log.info(`Electrobun app spawned (PID: ${proc.pid})`); + + const logHandlers: ReadlineInterface[] = []; + if (options.captureBackendLogs) { + const identifier = `electrobun-${port}`; + const onLine = (line: string): void => { + log.info(`[backend] ${line}`); + }; + const stdoutHandler = createLogCapture({ stream: proc.stdout, identifier, instanceId, onLine }); + if (stdoutHandler) { + logHandlers.push(stdoutHandler); + } + const stderrHandler = createLogCapture({ stream: proc.stderr, identifier, instanceId, onLine }); + if (stderrHandler) { + logHandlers.push(stderrHandler); + } + } + + // A post-spawn 'error' (e.g. ENOENT for a bad binary path) would otherwise be + // an uncaught exception. Surface it; the worker's CDP attach failing is the + // real signal that the app didn't come up. + proc.on('error', (err) => { + log.error(`Electrobun app process error: ${err.message}`); + }); + + return { proc, cleanupDirs: [cloneParentDir], port, logHandlers }; +} + +const CDP_READY_TIMEOUT_MS = 30_000; +const CDP_READY_POLL_MS = 250; + +/** + * Wait until CEF is serving its CDP `/json` endpoint with at least one `page` target. + * + * The launcher spawns the app and sets `goog:chromeOptions.debuggerAddress`, but the + * worker's Chromedriver attaches to that port independently. If it connects before CEF + * has bound the port and registered a page (slow on Windows CI), session creation + * times out ("cannot connect to chrome at localhost:N") and WDIO burns its full + * connectionRetryTimeout per attempt. Polling `/json` here makes the attach reliable. + * + * Resolves (with a warning) on timeout rather than throwing: a failed attach is the + * real signal, and a hard throw would mask it as a launcher error. + */ +export async function waitForCdpReady(port: number, timeoutMs: number = CDP_READY_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + // 127.0.0.1, not 'localhost': CEF binds the debugger on IPv4, but Node resolves + // 'localhost' to IPv6 ::1 first on Windows/Linux CI, so the fetch would always fail. + const url = `http://127.0.0.1:${port}/json`; + let lastError = 'no response'; + while (Date.now() < deadline) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2_000) }); + if (res.ok) { + const targets = (await res.json()) as Array<{ type?: string }>; + if (Array.isArray(targets) && targets.some((target) => target?.type === 'page')) { + return; + } + lastError = 'no page target yet'; + } + } catch (error) { + lastError = (error as Error).message; + } + await sleep(CDP_READY_POLL_MS); + } + log.warn( + `CEF CDP endpoint 127.0.0.1:${port} not ready after ${timeoutMs}ms (${lastError}); ` + + "proceeding — the worker's session attach may fail", + ); +} + +/** + * Stop a spawned Electrobun app: close log handlers, SIGTERM (SIGKILL after a + * grace period), then remove its temp dirs (the bundle-clone parent). Tolerant of an + * already-dead process and missing temp dirs. + */ +export async function stopElectrobunApp(app: ElectrobunAppProcess): Promise { + for (const handler of app.logHandlers) { + try { + handler.close(); + } catch { + // ignore + } + } + + const { proc } = app; + if (proc.pid && proc.exitCode === null && proc.signalCode === null) { + log.info(`Stopping Electrobun app (PID: ${proc.pid})…`); + proc.kill('SIGTERM'); + + const deadline = Date.now() + SIGKILL_GRACE_MS; + while (Date.now() < deadline) { + if (proc.exitCode !== null || proc.signalCode !== null) { + break; + } + await sleep(100); + } + if (proc.exitCode === null && proc.signalCode === null) { + log.warn('Electrobun app did not exit gracefully, sending SIGKILL'); + proc.kill('SIGKILL'); + // Brief reap-wait before rmSync removes the bundle clone — un-reaped CEF + // helper subprocesses can still hold handles inside the temp dir (EBUSY). + const killDeadline = Date.now() + SIGKILL_REAP_MS; + while (Date.now() < killDeadline) { + if (proc.exitCode !== null || proc.signalCode !== null) { + break; + } + await sleep(100); + } + } + } + + for (const dir of app.cleanupDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (error) { + log.warn(`Failed to remove Electrobun temp dir ${dir}: ${(error as Error).message}`); + } + } +} diff --git a/packages/electrobun-service/src/service.ts b/packages/electrobun-service/src/service.ts new file mode 100644 index 000000000..43b700b52 --- /dev/null +++ b/packages/electrobun-service/src/service.ts @@ -0,0 +1,204 @@ +import { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import type { ElectrobunServiceAPI } from '@wdio/native-types'; +import { createLogger } from '@wdio/native-utils'; + +import { clearAllMocks, isMockFunction, resetAllMocks, restoreAllMocks } from './commands/allMocks.js'; +import { execute } from './commands/execute.js'; +import { mock } from './commands/mock.js'; +import { triggerDeeplink } from './commands/triggerDeeplink.js'; +import { DEFAULT_REMOTE_DEBUGGING_PORT, SERVICE_NAME } from './constants.js'; +import { ElectrobunMockStore } from './mockStore.js'; +import type { ElectrobunServiceOptions } from './types.js'; + +const log = createLogger(SERVICE_NAME, 'service'); + +/** Parse a `host:port` debuggerAddress into its parts. Defaults the host to localhost. */ +function parseDebuggerAddress(address: string): { host: string; port: number } { + const lastColon = address.lastIndexOf(':'); + if (lastColon === -1) { + return { host: 'localhost', port: DEFAULT_REMOTE_DEBUGGING_PORT }; + } + const host = address.slice(0, lastColon) || 'localhost'; + const port = Number.parseInt(address.slice(lastColon + 1), 10); + return { host, port: Number.isNaN(port) ? DEFAULT_REMOTE_DEBUGGING_PORT : port }; +} + +/** + * Worker-process service for `@wdio/electrobun-service`. Attaches a CdpBridge to + * the CEF debugger endpoint (`goog:chromeOptions.debuggerAddress`, set by the + * launcher) and installs the `browser.electrobun.*` surface (execute, window + * switching, mocking, and triggerDeeplink — all over the bridge). + */ +export default class ElectrobunWorkerService { + private options: ElectrobunServiceOptions; + private bridges: CdpBridge[] = []; + private mockStores: ElectrobunMockStore[] = []; + + constructor(options: ElectrobunServiceOptions, capabilities: unknown) { + const capOptions = (capabilities as { 'wdio:electrobunServiceOptions'?: ElectrobunServiceOptions })[ + 'wdio:electrobunServiceOptions' + ]; + this.options = { ...options, ...capOptions }; + log.debug('ElectrobunWorkerService initialised'); + } + + async before( + capabilities: unknown, + _specs: string[], + browser: WebdriverIO.Browser | WebdriverIO.MultiRemoteBrowser, + ): Promise { + // Browser mode: the frontend runs in a plain Chrome session against a dev + // server — no CEF, no CDP side-channel. The electrobun surface is not + // installed (execute/mock have no in-app target to drive). Keyed on `mode`, + // the same criterion the launcher uses — a stray devServerUrl without + // mode: 'browser' must not skip the attach while the launcher spawns natively. + if (this.options.mode === 'browser') { + log.info('Browser mode — skipping CDP bridge attach'); + return; + } + + if (browser.isMultiremote) { + const mrBrowser = browser as WebdriverIO.MultiRemoteBrowser; + for (const instanceName of mrBrowser.instances) { + const instance = mrBrowser.getInstance(instanceName); + await this.attachInstance(instance, capabilityFor(capabilities, instanceName)); + } + return; + } + + await this.attachInstance(browser as WebdriverIO.Browser, capabilities); + } + + private async attachInstance(browser: WebdriverIO.Browser, capabilities: unknown): Promise { + const chromeOptions = (capabilities as { 'goog:chromeOptions'?: { debuggerAddress?: string } } | undefined)?.[ + 'goog:chromeOptions' + ]; + const debuggerAddress = chromeOptions?.debuggerAddress; + + if (!debuggerAddress) { + log.warn( + 'No goog:chromeOptions.debuggerAddress on the capability — cannot attach the CDP bridge. ' + + 'browser.electrobun.* will be unavailable. Was the launcher (native mode) run?', + ); + return; + } + + const { host, port } = parseDebuggerAddress(debuggerAddress); + log.info(`Attaching CDP bridge to ${host}:${port}`); + + const bridge = new CdpBridge({ + host, + port, + timeout: this.options.cdpConnectionTimeout, + waitInterval: this.options.cdpConnectionRetryInterval, + connectionRetryCount: this.options.cdpConnectionRetryCount, + }); + await bridge.connect(); + this.bridges.push(bridge); + + const mockStore = new ElectrobunMockStore(); + this.mockStores.push(mockStore); + installApi(browser, bridge, mockStore); + + await syncWebDriverWindow(browser, bridge); + } + + async after(): Promise { + await this.closeBridges(); + } + + async afterSession(): Promise { + await this.closeBridges(); + } + + private async closeBridges(): Promise { + for (const bridge of this.bridges) { + await bridge.close().catch((error: Error) => { + log.warn(`Failed to close CDP bridge: ${error.message}`); + }); + } + this.bridges = []; + for (const store of this.mockStores) { + store.clear(); + } + this.mockStores = []; + } +} + +/** Resolve the per-instance capability from a multiremote capabilities map. */ +function capabilityFor(capabilities: unknown, instanceName: string): unknown { + if (capabilities && typeof capabilities === 'object' && instanceName in (capabilities as Record)) { + const entry = (capabilities as Record)[instanceName]; + return entry?.capabilities ?? entry ?? capabilities; + } + return capabilities; +} + +/** + * Align the WebDriver session window with the CdpBridge's active target. Chromedriver, + * attaching to the CEF debug port, makes its session window whatever page CEF lists + * first — often a blank shell (`about:blank`) or the wrong content window when several + * are open (the fixture opens main + second). The bridge (execute/mock) tracks its own + * active target, so without this `$()`/`click`/`getText` would drift onto a different + * window. Call after attach and after every `switchWindow` so element commands and + * `execute` stay on the same window. Matches the active target by URL, falling back to + * the first non-blank window. Best-effort: failures are logged, not fatal. + */ +async function syncWebDriverWindow(browser: WebdriverIO.Browser, bridge: CdpBridge): Promise { + try { + const targets = bridge.listTargets(); + const targetUrl = targets.find((t) => t.label === bridge.activeLabel)?.url ?? targets[0]?.url; + const handles = await browser.getWindowHandles(); + const originalHandle = await browser.getWindowHandle().catch(() => undefined); + let fallback: string | undefined; + for (const handle of handles) { + await browser.switchToWindow(handle); + const url = await browser.getUrl().catch(() => ''); + if (targetUrl && url === targetUrl) { + return; + } + if (!fallback && url && !url.startsWith('about:') && !url.startsWith('chrome')) { + fallback = handle; + } + } + if (fallback) { + await browser.switchToWindow(fallback); + return; + } + // No match: don't strand the session on the last handle we probed — restore the + // caller's original active window so element commands stay where they were. + if (originalHandle) { + await browser.switchToWindow(originalHandle).catch(() => {}); + } + log.warn('No non-blank content window found; element commands may target a blank document.'); + } catch (error) { + log.warn(`Could not sync the WebDriver window to the active target: ${(error as Error).message}`); + } +} + +/** + * Build and install the `browser.electrobun.*` surface backed by `bridge`. The + * `mockStore` is per-installed-instance so multiremote instances keep separate + * mock registries. + */ +function installApi(browser: WebdriverIO.Browser, bridge: CdpBridge, mockStore: ElectrobunMockStore): void { + const electrobun: ElectrobunServiceAPI = { + execute: (script: Parameters>[1], ...args: A): Promise => + execute(bridge, script, ...args), + switchWindow: async (label: string) => { + await bridge.switchTarget(label); + // Move the WebDriver session window too — $/click must follow the switch, not + // just execute/mock (which use the bridge's active target). + await syncWebDriverWindow(browser, bridge); + }, + listWindows: async () => bridge.listWindows(), + mock: (target: string) => mock(target, bridge, mockStore), + isMockFunction: (targetOrFn: unknown) => isMockFunction(targetOrFn, mockStore), + clearAllMocks: (prefix?: string) => clearAllMocks(mockStore, prefix), + resetAllMocks: (prefix?: string) => resetAllMocks(mockStore, prefix), + restoreAllMocks: (prefix?: string) => restoreAllMocks(mockStore, prefix), + triggerDeeplink: (url: string) => triggerDeeplink(url), + }; + (browser as unknown as { electrobun: ElectrobunServiceAPI }).electrobun = electrobun; + log.debug('Installed browser.electrobun.*'); +} diff --git a/packages/electrobun-service/src/serviceConfig.ts b/packages/electrobun-service/src/serviceConfig.ts new file mode 100644 index 000000000..ee028923e --- /dev/null +++ b/packages/electrobun-service/src/serviceConfig.ts @@ -0,0 +1,25 @@ +// Option resolution shared by the launcher and worker service: merge global +// (service-level) options with per-capability options, capability taking +// precedence, and pull the custom-capability options off a capability object. + +import { CUSTOM_CAPABILITY_NAME } from './constants.js'; +import type { ElectrobunServiceGlobalOptions, ElectrobunServiceOptions } from './types.js'; + +/** Read the `wdio:electrobunServiceOptions` block off a capability, if present. */ +export function getServiceOptionsFromCapability( + capability: { [CUSTOM_CAPABILITY_NAME]?: ElectrobunServiceOptions } | undefined, +): ElectrobunServiceOptions | undefined { + return capability?.[CUSTOM_CAPABILITY_NAME]; +} + +/** + * Merge service-level global options with per-capability options. Capability + * options win on conflict, matching the precedence used across the sibling + * services. + */ +export function mergeServiceOptions( + globalOptions: ElectrobunServiceGlobalOptions = {}, + capabilityOptions: ElectrobunServiceOptions | undefined, +): ElectrobunServiceOptions { + return { ...globalOptions, ...capabilityOptions }; +} diff --git a/packages/electrobun-service/src/session.ts b/packages/electrobun-service/src/session.ts new file mode 100644 index 000000000..6a5565e20 --- /dev/null +++ b/packages/electrobun-service/src/session.ts @@ -0,0 +1,138 @@ +// Standalone (`remote()`) session helpers for `@wdio/electrobun-service`. +// +// Mirrors the dioxus session API shape (createCapabilities / init / cleanup) but +// follows the CDP-attach flow (like @wdio/electron-service): WDIO's `remote()` +// only runs worker-level hooks, so init() manually drives the launcher's +// onPrepare + onWorkerStart to resolve+spawn the app and set +// `goog:chromeOptions.debuggerAddress` before opening the Chromedriver session. + +import type { + ElectrobunCapabilities, + ElectrobunServiceGlobalOptions, + ElectrobunServiceOptions, +} from '@wdio/native-types'; +import { createLogger } from '@wdio/native-utils'; +import type { Options } from '@wdio/types'; +import { remote } from 'webdriverio'; + +import { CUSTOM_CAPABILITY_NAME } from './constants.js'; +import ElectrobunLaunchService from './launcher.js'; +import ElectrobunWorkerService from './service.js'; +import { mergeServiceOptions } from './serviceConfig.js'; + +const log = createLogger('electrobun-service', 'session'); + +const activeLaunchers = new WeakMap(); +const activeServices = new WeakMap(); + +/** + * Initialise an Electrobun standalone session. + * + * Drives the launcher's onPrepare + onWorkerStart manually (WDIO's `remote()` + * runs only worker hooks) so the app is spawned and the CEF debugger endpoint is + * pinned onto the capability, then opens the Chromedriver session and installs + * the `browser.electrobun.*` surface. + */ +export async function init( + capabilities: ElectrobunCapabilities, + globalOptions?: ElectrobunServiceGlobalOptions, +): Promise { + log.debug('Initializing Electrobun service in standalone mode…'); + + const capability = Array.isArray(capabilities) ? capabilities[0] : capabilities; + const testRunnerOpts = { capabilities: [] } as unknown as Options.Testrunner; + const launcher = new ElectrobunLaunchService(globalOptions ?? {}, capability, testRunnerOpts); + + await launcher.onPrepare(testRunnerOpts, [capability]); + await launcher.onWorkerStart('', [capability]); + + const browser = await remote({ + capabilities: capability as WebdriverIO.Capabilities, + }).catch(async (error: Error) => { + log.error(`Failed to create remote session: ${error.message}`); + await launcher + .onComplete() + .catch((cleanupErr: Error) => log.warn(`Failed to stop app during cleanup: ${cleanupErr.message}`)); + throw error; + }); + + activeLaunchers.set(browser, launcher); + + // Same global log.warn(`Failed to delete session during service.before cleanup: ${e.message}`)); + await launcher + .onComplete() + .catch((e: Error) => log.warn(`Failed to stop app during service.before cleanup: ${e.message}`)); + activeLaunchers.delete(browser); + throw error; + } + activeServices.set(browser, service); + + log.debug('Electrobun standalone session initialised'); + return browser; +} + +/** + * Clean up a standalone Electrobun session created by {@link init}. A browser + * not created by init() is left untouched (warn + no-op) — its WebDriver + * session belongs to whoever opened it. + */ +export async function cleanup(browser: WebdriverIO.Browser): Promise { + log.debug('Cleaning up Electrobun standalone session…'); + + const launcher = activeLaunchers.get(browser); + if (!launcher) { + log.warn('No launcher found for this browser instance'); + return; + } + + const service = activeServices.get(browser); + try { + // One call is the whole worker teardown: after() and afterSession() both + // delegate to the same closeBridges() (the testrunner calls whichever hook + // fires) — unlike the dioxus cleanup, where the two hooks do different work. + await service?.after(); + } catch (e) { + log.warn(`service.after() failed during cleanup: ${(e as Error).message}`); + } finally { + activeServices.delete(browser); + } + + try { + if (browser.sessionId) { + await browser.deleteSession(); + } + } catch (e) { + log.warn(`Failed to delete session during cleanup: ${(e as Error).message}`); + } + + try { + await launcher.onComplete(); + } catch (e) { + log.warn(`launcher.onComplete() failed during cleanup: ${(e as Error).message}`); + } finally { + activeLaunchers.delete(browser); + } + log.debug('Electrobun standalone session cleaned up'); +} + +/** Build a minimal ElectrobunCapabilities object for standalone use. */ +export function createElectrobunCapabilities(options: ElectrobunServiceOptions): ElectrobunCapabilities { + if (options.mode !== 'browser' && !options.appBinaryPath) { + throw new Error('appBinaryPath is required for native-mode Electrobun standalone sessions'); + } + + return { + browserName: 'electrobun', + [CUSTOM_CAPABILITY_NAME]: { ...options }, + }; +} diff --git a/packages/electrobun-service/src/types.ts b/packages/electrobun-service/src/types.ts new file mode 100644 index 000000000..274895105 --- /dev/null +++ b/packages/electrobun-service/src/types.ts @@ -0,0 +1,11 @@ +// Extended service options for `@wdio/electrobun-service`. Builds on the shared +// types in @wdio/native-types with any implementation-specific fields the +// launcher / service need. Kept as a thin alias for now — diverge here only +// when the implementation requires a field that doesn't belong in the public +// @wdio/native-types surface. + +import type { ElectrobunServiceOptions as BaseElectrobunServiceOptions } from '@wdio/native-types'; + +export type { ElectrobunCapabilities, ElectrobunServiceGlobalOptions } from '@wdio/native-types'; + +export type ElectrobunServiceOptions = BaseElectrobunServiceOptions; diff --git a/packages/electrobun-service/test/allMocks.spec.ts b/packages/electrobun-service/test/allMocks.spec.ts new file mode 100644 index 000000000..c8bb6844f --- /dev/null +++ b/packages/electrobun-service/test/allMocks.spec.ts @@ -0,0 +1,151 @@ +import vm from 'node:vm'; + +import type { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@wdio/electrobun-cdp-bridge', () => ({ CdpBridge: class {} })); + +import { clearAllMocks, isMockFunction, resetAllMocks, restoreAllMocks } from '../src/commands/allMocks.js'; +import { createMock } from '../src/mock.js'; +import { ElectrobunMockStore } from '../src/mockStore.js'; + +interface FakeWindow { + __WDIO_ELECTROBUN_MOCKS__?: Record; + [key: string]: unknown; +} + +function makeBridge(initialWindow: FakeWindow): { bridge: CdpBridge; window: FakeWindow } { + const sandbox: { window: FakeWindow } = { window: initialWindow }; + vm.createContext(sandbox); + Object.defineProperty(sandbox, 'globalThis', { value: sandbox }); + const send = vi.fn(async (_method: string, params: { expression: string }) => { + const value = await Promise.resolve(vm.runInContext(params.expression, sandbox)); + return { result: { value: value === undefined ? undefined : JSON.parse(JSON.stringify(value)) } }; + }); + return { bridge: { send } as unknown as CdpBridge, window: initialWindow }; +} + +describe('allMocks helpers', () => { + let store: ElectrobunMockStore; + + beforeEach(() => { + store = new ElectrobunMockStore(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('isMockFunction', () => { + it('should return true for an ElectrobunMock', async () => { + const { bridge } = makeBridge({ api: { a: () => 1 } }); + const mock = await createMock('api.a', bridge, store); + expect(isMockFunction(mock, store)).toBe(true); + }); + + it('should return true for a registered target-path string', async () => { + const { bridge } = makeBridge({ api: { a: () => 1 } }); + await createMock('api.a', bridge, store); + expect(isMockFunction('api.a', store)).toBe(true); + expect(isMockFunction('api.unknown', store)).toBe(false); + }); + + it('should return false for a plain function or non-function', () => { + expect(isMockFunction(() => 1, store)).toBe(false); + expect(isMockFunction(undefined, store)).toBe(false); + expect(isMockFunction(42, store)).toBe(false); + }); + }); + + describe('clear/reset/restore all', () => { + it('should clear history across all mocks', async () => { + const { bridge, window } = makeBridge({ api: { a: () => 1, b: () => 2 } }); + const a = await createMock('api.a', bridge, store); + const b = await createMock('api.b', bridge, store); + (window.api as { a: () => unknown }).a(); + (window.api as { b: () => unknown }).b(); + await a.update(); + await b.update(); + expect(a.mock.calls).toHaveLength(1); + expect(b.mock.calls).toHaveLength(1); + + await clearAllMocks(store); + + expect(a.mock.calls).toHaveLength(0); + expect(b.mock.calls).toHaveLength(0); + }); + + it('should honour a prefix filter', async () => { + const { bridge, window } = makeBridge({ fs: { read: () => 1 }, net: { get: () => 2 } }); + const read = await createMock('fs.read', bridge, store); + const get = await createMock('net.get', bridge, store); + (window.fs as { read: () => unknown }).read(); + (window.net as { get: () => unknown }).get(); + await read.update(); + await get.update(); + + await clearAllMocks(store, 'fs.'); + + expect(read.mock.calls).toHaveLength(0); + expect(get.mock.calls).toHaveLength(1); + }); + + it('should not clear a sibling namespace that shares a prefix string', async () => { + const { bridge, window } = makeBridge({ api: { a: () => 1 }, api2: { b: () => 2 } }); + const a = await createMock('api.a', bridge, store); + const b = await createMock('api2.b', bridge, store); + (window.api as { a: () => unknown }).a(); + (window.api2 as { b: () => unknown }).b(); + await a.update(); + await b.update(); + + await clearAllMocks(store, 'api'); + + expect(a.mock.calls).toHaveLength(0); + expect(b.mock.calls).toHaveLength(1); + }); + + it('should continue the bulk op when one mock throws', async () => { + const { bridge, window } = makeBridge({ api: { a: () => 1, b: () => 2 } }); + const a = await createMock('api.a', bridge, store); + const b = await createMock('api.b', bridge, store); + (window.api as { a: () => unknown }).a(); + (window.api as { b: () => unknown }).b(); + await a.update(); + await b.update(); + expect(a.mock.calls).toHaveLength(1); + expect(b.mock.calls).toHaveLength(1); + vi.spyOn(a, 'mockClear').mockRejectedValueOnce(new Error('cdp connection dropped')); + + await clearAllMocks(store); + + // a threw and was skipped, but b must still be cleared. + expect(b.mock.calls).toHaveLength(0); + }); + + it('should reset implementations across all mocks', async () => { + const { bridge, window } = makeBridge({ api: { a: () => 1 } }); + const a = await createMock('api.a', bridge, store); + await a.mockReturnValue(5); + expect((window.api as { a: () => unknown }).a()).toBe(5); + + await resetAllMocks(store); + + expect((window.api as { a: () => unknown }).a()).toBeUndefined(); + }); + + it('should restore originals and empty the store on restoreAllMocks', async () => { + const origA = (): string => 'a'; + const origB = (): string => 'b'; + const { bridge, window } = makeBridge({ api: { a: origA, b: origB } }); + await createMock('api.a', bridge, store); + await createMock('api.b', bridge, store); + + await restoreAllMocks(store); + + expect((window.api as { a: unknown }).a).toBe(origA); + expect((window.api as { b: unknown }).b).toBe(origB); + expect(store.getMocks()).toHaveLength(0); + }); + }); +}); diff --git a/packages/electrobun-service/test/electrobunConfig.spec.ts b/packages/electrobun-service/test/electrobunConfig.spec.ts new file mode 100644 index 000000000..ce6095dc7 --- /dev/null +++ b/packages/electrobun-service/test/electrobunConfig.spec.ts @@ -0,0 +1,280 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SevereServiceError } from 'webdriverio'; + +import { + type BuildJson, + getRemoteDebuggingPort, + readBuildJson, + resolveElectrobunApp, + verifyCefRenderer, + writeRemoteDebuggingPort, +} from '../src/electrobunConfig.js'; + +const CEF_FRAMEWORK = 'Chromium Embedded Framework.framework'; + +interface FakeAppOptions { + withFramework?: boolean; + buildJson?: BuildJson | undefined; + bundleName?: string; +} + +/** + * Build a fake macOS `.app` tree under a temp dir. Returns useful paths. + */ +function makeFakeMacApp(root: string, opts: FakeAppOptions = {}): { appDir: string; binaryPath: string } { + const bundleName = opts.bundleName ?? 'Demo'; + const appDir = join(root, `${bundleName}.app`); + const macosDir = join(appDir, 'Contents', 'MacOS'); + const resourcesDir = join(appDir, 'Contents', 'Resources'); + mkdirSync(macosDir, { recursive: true }); + mkdirSync(resourcesDir, { recursive: true }); + + const binaryPath = join(macosDir, bundleName); + writeFileSync(binaryPath, '#!/bin/sh\n', 'utf8'); + + if (opts.buildJson !== undefined) { + writeFileSync(join(resourcesDir, 'build.json'), JSON.stringify(opts.buildJson, null, 2), 'utf8'); + } + + if (opts.withFramework) { + mkdirSync(join(appDir, 'Contents', 'Frameworks', CEF_FRAMEWORK), { recursive: true }); + } + + return { appDir, binaryPath }; +} + +describe('electrobunConfig', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'eb-config-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + describe('resolveElectrobunApp', () => { + it('should throw a SevereServiceError when appBinaryPath is missing', () => { + expect(() => resolveElectrobunApp(undefined, 'darwin')).toThrow(SevereServiceError); + expect(() => resolveElectrobunApp(undefined, 'darwin')).toThrow(/explicit appBinaryPath/); + }); + + it('should throw a SevereServiceError when the path does not exist', () => { + const missing = join(root, 'NoSuch.app'); + expect(() => resolveElectrobunApp(missing, 'darwin')).toThrow(SevereServiceError); + expect(() => resolveElectrobunApp(missing, 'darwin')).toThrow(/does not exist/); + }); + + it('should resolve from a macOS .app directory', () => { + const { appDir } = makeFakeMacApp(root, { buildJson: { identifier: 'com.example.demo' } }); + + const resolved = resolveElectrobunApp(appDir, 'darwin'); + + expect(resolved.bundlePath).toBe(appDir); + expect(resolved.resourcesDir).toBe(join(appDir, 'Contents', 'Resources')); + expect(resolved.buildJsonPath).toBe(join(appDir, 'Contents', 'Resources', 'build.json')); + expect(resolved.binaryPath).toBe(join(appDir, 'Contents', 'MacOS', 'Demo')); + expect(resolved.identifier).toBe('com.example.demo'); + }); + + it('should resolve from the inner macOS binary path', () => { + const { appDir, binaryPath } = makeFakeMacApp(root, { buildJson: { identifier: 'com.example.demo' } }); + + const resolved = resolveElectrobunApp(binaryPath, 'darwin'); + + expect(resolved.bundlePath).toBe(appDir); + expect(resolved.binaryPath).toBe(binaryPath); + expect(resolved.buildJsonPath).toBe(join(appDir, 'Contents', 'Resources', 'build.json')); + }); + + it('should resolve a sibling build.json on non-macOS platforms', () => { + const binDir = join(root, 'linux-app'); + mkdirSync(binDir, { recursive: true }); + const binaryPath = join(binDir, 'demo'); + writeFileSync(binaryPath, '#!/bin/sh\n', 'utf8'); + writeFileSync(join(binDir, 'build.json'), JSON.stringify({ identifier: 'com.example.linux' }), 'utf8'); + + const resolved = resolveElectrobunApp(binaryPath, 'linux'); + + expect(resolved.binaryPath).toBe(binaryPath); + expect(resolved.bundlePath).toBe(binDir); + expect(resolved.resourcesDir).toBe(binDir); + expect(resolved.buildJsonPath).toBe(join(binDir, 'build.json')); + expect(resolved.identifier).toBe('com.example.linux'); + }); + + it('should resolve the electrobun bin/launcher layout (build.json under Resources) on non-macOS', () => { + // Linux/Windows bundle: /bin/launcher[.exe] + /Resources/build.json. + const appRoot = join(root, 'WDIOElectrobunE2E-dev'); + const binDir = join(appRoot, 'bin'); + const resourcesDir = join(appRoot, 'Resources'); + mkdirSync(binDir, { recursive: true }); + mkdirSync(resourcesDir, { recursive: true }); + const binaryPath = join(binDir, 'launcher'); + writeFileSync(binaryPath, '#!/bin/sh\n', 'utf8'); + writeFileSync( + join(resourcesDir, 'build.json'), + JSON.stringify({ identifier: 'com.wdio.electrobun.e2e' }), + 'utf8', + ); + + const resolved = resolveElectrobunApp(binaryPath, 'linux'); + + expect(resolved.binaryPath).toBe(binaryPath); + expect(resolved.bundlePath).toBe(appRoot); + expect(resolved.resourcesDir).toBe(resourcesDir); + expect(resolved.buildJsonPath).toBe(join(resourcesDir, 'build.json')); + expect(resolved.identifier).toBe('com.wdio.electrobun.e2e'); + }); + }); + + describe('verifyCefRenderer', () => { + it('should pass on macOS when the CEF framework is present', () => { + const { appDir } = makeFakeMacApp(root, { withFramework: true, buildJson: {} }); + const resolved = resolveElectrobunApp(appDir, 'darwin'); + + expect(() => verifyCefRenderer(resolved, 'darwin')).not.toThrow(); + }); + + it('should pass on macOS when build.json indicates the cef renderer', () => { + const { appDir } = makeFakeMacApp(root, { buildJson: { renderer: 'cef' } }); + const resolved = resolveElectrobunApp(appDir, 'darwin'); + + expect(() => verifyCefRenderer(resolved, 'darwin')).not.toThrow(); + }); + + it('should pass on macOS when build.json pins a remote-debugging port', () => { + const { appDir } = makeFakeMacApp(root, { + buildJson: { chromiumFlags: { 'remote-debugging-port': '9333' } }, + }); + const resolved = resolveElectrobunApp(appDir, 'darwin'); + + expect(() => verifyCefRenderer(resolved, 'darwin')).not.toThrow(); + }); + + it('should throw cefRendererRequired on macOS when CEF is absent', () => { + const { appDir } = makeFakeMacApp(root, { buildJson: { renderer: 'webview' } }); + const resolved = resolveElectrobunApp(appDir, 'darwin'); + + expect(() => verifyCefRenderer(resolved, 'darwin')).toThrow(SevereServiceError); + expect(() => verifyCefRenderer(resolved, 'darwin')).toThrow(/CEF renderer/); + }); + + it('should throw cefRendererRequired on macOS when build.json is missing entirely', () => { + const { appDir } = makeFakeMacApp(root, { buildJson: undefined }); + const resolved = resolveElectrobunApp(appDir, 'darwin'); + + expect(() => verifyCefRenderer(resolved, 'darwin')).toThrow(/CEF renderer/); + }); + + it('should not false-negative on non-macOS when CEF cannot be confirmed', () => { + const binDir = join(root, 'linux-app'); + mkdirSync(binDir, { recursive: true }); + const binaryPath = join(binDir, 'demo'); + writeFileSync(binaryPath, '#!/bin/sh\n', 'utf8'); + const resolved = resolveElectrobunApp(binaryPath, 'linux'); + + expect(() => verifyCefRenderer(resolved, 'linux')).not.toThrow(); + }); + + it('should pass on non-macOS when build.json indicates cef', () => { + const binDir = join(root, 'linux-app'); + mkdirSync(binDir, { recursive: true }); + const binaryPath = join(binDir, 'demo'); + writeFileSync(binaryPath, '#!/bin/sh\n', 'utf8'); + writeFileSync(join(binDir, 'build.json'), JSON.stringify({ defaultRenderer: 'cef' }), 'utf8'); + const resolved = resolveElectrobunApp(binaryPath, 'linux'); + + expect(() => verifyCefRenderer(resolved, 'linux')).not.toThrow(); + }); + }); + + describe('readBuildJson', () => { + it('should return undefined when build.json does not exist', () => { + expect(readBuildJson(join(root, 'nope', 'build.json'))).toBeUndefined(); + }); + + it('should return undefined when build.json is not valid JSON', () => { + const p = join(root, 'build.json'); + writeFileSync(p, '{ not json', 'utf8'); + expect(readBuildJson(p)).toBeUndefined(); + }); + + it('should parse a valid build.json', () => { + const p = join(root, 'build.json'); + writeFileSync(p, JSON.stringify({ identifier: 'com.example.demo', chromiumFlags: {} }), 'utf8'); + expect(readBuildJson(p)?.identifier).toBe('com.example.demo'); + }); + }); + + describe('getRemoteDebuggingPort', () => { + it('should return undefined when no port is pinned', () => { + expect(getRemoteDebuggingPort({ chromiumFlags: {} })).toBeUndefined(); + expect(getRemoteDebuggingPort(undefined)).toBeUndefined(); + }); + + it('should parse the pinned port string into a number', () => { + expect(getRemoteDebuggingPort({ chromiumFlags: { 'remote-debugging-port': '9333' } })).toBe(9333); + }); + + it('should return undefined for a non-numeric pinned value', () => { + expect(getRemoteDebuggingPort({ chromiumFlags: { 'remote-debugging-port': 'abc' } })).toBeUndefined(); + }); + }); + + describe('writeRemoteDebuggingPort', () => { + it('should write the port as a string and round-trip via readBuildJson', () => { + const p = join(root, 'build.json'); + writeFileSync(p, JSON.stringify({ identifier: 'com.example.demo', name: 'Demo' }), 'utf8'); + + writeRemoteDebuggingPort(p, 9350); + + const after = readBuildJson(p); + expect(after?.chromiumFlags?.['remote-debugging-port']).toBe('9350'); + expect(getRemoteDebuggingPort(after)).toBe(9350); + // Other keys are preserved. + expect(after?.identifier).toBe('com.example.demo'); + expect(after?.name).toBe('Demo'); + }); + + it('should preserve existing chromiumFlags when pinning the port', () => { + const p = join(root, 'build.json'); + writeFileSync(p, JSON.stringify({ chromiumFlags: { 'disable-gpu': 'true' } }), 'utf8'); + + writeRemoteDebuggingPort(p, 9351); + + const after = readBuildJson(p); + expect(after?.chromiumFlags?.['disable-gpu']).toBe('true'); + expect(after?.chromiumFlags?.['remote-debugging-port']).toBe('9351'); + }); + + it('should also pin user-data-dir when provided (and omit it when not)', () => { + const p = join(root, 'build.json'); + writeFileSync(p, JSON.stringify({ name: 'Demo' }), 'utf8'); + + writeRemoteDebuggingPort(p, 9352, '/tmp/wdio-electrobun-userdata-abc'); + expect(readBuildJson(p)?.chromiumFlags?.['user-data-dir']).toBe('/tmp/wdio-electrobun-userdata-abc'); + + // Omitted → no user-data-dir key written. + writeFileSync(p, JSON.stringify({ name: 'Demo' }), 'utf8'); + writeRemoteDebuggingPort(p, 9353); + expect(readBuildJson(p)?.chromiumFlags?.['user-data-dir']).toBeUndefined(); + }); + + it('should throw a SevereServiceError when build.json does not exist', () => { + expect(() => writeRemoteDebuggingPort(join(root, 'missing', 'build.json'), 9333)).toThrow(SevereServiceError); + expect(() => writeRemoteDebuggingPort(join(root, 'missing', 'build.json'), 9333)).toThrow(/not found/); + }); + + it('should throw a SevereServiceError when build.json is not valid JSON', () => { + const p = join(root, 'build.json'); + writeFileSync(p, '{ broken', 'utf8'); + expect(() => writeRemoteDebuggingPort(p, 9333)).toThrow(/not valid JSON/); + }); + }); +}); diff --git a/packages/electrobun-service/test/errors.spec.ts b/packages/electrobun-service/test/errors.spec.ts new file mode 100644 index 000000000..bda82f92d --- /dev/null +++ b/packages/electrobun-service/test/errors.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { SevereServiceError } from 'webdriverio'; + +import { cefNativeModeMacOnly, cefRendererRequired, deeplinkUnsupportedOnPlatform } from '../src/errors.js'; + +describe('cefRendererRequired', () => { + it('should be a SevereServiceError so the runner aborts', () => { + const err = cefRendererRequired('darwin'); + expect(err).toBeInstanceOf(SevereServiceError); + }); + + it('should explain the CEF renderer requirement', () => { + const err = cefRendererRequired('darwin'); + expect(err.message).toContain('CEF renderer'); + expect(err.message).toContain('electrobun.config.ts'); + }); + + it('should name the current platform in the message', () => { + expect(cefRendererRequired('linux').message).toContain('linux'); + expect(cefRendererRequired('win32').message).toContain('win32'); + }); +}); + +describe('cefNativeModeMacOnly', () => { + it('should be a SevereServiceError so the runner aborts', () => { + expect(cefNativeModeMacOnly('linux')).toBeInstanceOf(SevereServiceError); + }); + + it('should state macOS-only and name the unsupported platform', () => { + expect(cefNativeModeMacOnly('linux').message).toMatch(/macOS/); + expect(cefNativeModeMacOnly('linux').message).toContain('linux'); + expect(cefNativeModeMacOnly('win32').message).toContain('win32'); + }); + + it('should point to browser mode and the #317 native-renderer follow-up', () => { + const err = cefNativeModeMacOnly('win32'); + expect(err.message).toContain("mode: 'browser'"); + expect(err.message).toContain('issues/317'); + }); +}); + +describe('deeplinkUnsupportedOnPlatform', () => { + it('should be a plain Error (a recoverable command rejection, not severe)', () => { + const err = deeplinkUnsupportedOnPlatform('win32'); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(SevereServiceError); + }); + + it('should state that only macOS is supported and name the platform', () => { + const err = deeplinkUnsupportedOnPlatform('linux'); + expect(err.message).toContain('macOS'); + expect(err.message).toContain('linux'); + }); +}); diff --git a/packages/electrobun-service/test/execute.spec.ts b/packages/electrobun-service/test/execute.spec.ts new file mode 100644 index 000000000..0cb30e2ca --- /dev/null +++ b/packages/electrobun-service/test/execute.spec.ts @@ -0,0 +1,76 @@ +import type { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import { describe, expect, it, vi } from 'vitest'; + +import { execute } from '../src/commands/execute.js'; + +function makeBridge(response: unknown): { bridge: CdpBridge; send: ReturnType } { + const send = vi.fn().mockResolvedValue(response); + const bridge = { send } as unknown as CdpBridge; + return { bridge, send }; +} + +describe('execute', () => { + it('should inline function args as JSON literals into the IIFE', async () => { + const { bridge, send } = makeBridge({ result: { value: 'done' } }); + + await execute(bridge, (_eb, a: number, b: string) => `${a}${b}`, 7, 'x'); + + const expression = send.mock.calls[0][1].expression as string; + expect(expression).toContain('7, "x"'); + expect(expression).toContain('__WDIO_ELECTROBUN__'); + }); + + it('should return the evaluated value', async () => { + const { bridge } = makeBridge({ result: { value: { ok: true } } }); + + const result = await execute(bridge, () => ({ ok: true })); + + expect(result).toEqual({ ok: true }); + }); + + it('should wrap a bare string expression so its value is returned', async () => { + const { bridge, send } = makeBridge({ result: { value: 2 } }); + + await execute(bridge, 'document.title'); + + expect(send.mock.calls[0][1].expression).toBe('(async function () { return (document.title); })()'); + }); + + it('should wrap a statement-style string (leading return) as a function body', async () => { + const { bridge, send } = makeBridge({ result: { value: 42 } }); + + await execute(bridge, 'return 42'); + + expect(send.mock.calls[0][1].expression).toBe('(async function () { return 42 })()'); + }); + + it('should throw a descriptive error for non-JSON-serialisable args', async () => { + const { bridge } = makeBridge({ result: { value: undefined } }); + const circular: Record = {}; + circular.self = circular; + + await expect(execute(bridge, (_eb, _arg: unknown) => undefined, circular)).rejects.toThrow(/not JSON-serialisable/); + }); + + it('should reject function/symbol args instead of silently dropping them', async () => { + const { bridge, send } = makeBridge({ result: { value: undefined } }); + + await expect( + execute( + bridge, + (_eb, _arg: unknown) => undefined, + () => 1, + ), + ).rejects.toThrow(/not JSON-serialisable/); + await expect(execute(bridge, (_eb, _arg: unknown) => undefined, Symbol('s'))).rejects.toThrow( + /not JSON-serialisable/, + ); + expect(send).not.toHaveBeenCalled(); + }); + + it('should surface Runtime.evaluate exceptionDetails as a thrown error', async () => { + const { bridge } = makeBridge({ exceptionDetails: { text: 'ReferenceError: x is not defined' } }); + + await expect(execute(bridge, () => undefined)).rejects.toThrow(/x is not defined/); + }); +}); diff --git a/packages/electrobun-service/test/index.spec.ts b/packages/electrobun-service/test/index.spec.ts new file mode 100644 index 000000000..a027956f7 --- /dev/null +++ b/packages/electrobun-service/test/index.spec.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +describe('@wdio/electrobun-service public exports', () => { + it('should expose the worker service as the default export', async () => { + const mod = await import('../src/index.js'); + expect(mod.default).toBeTypeOf('function'); + expect(mod.default.name).toBe('ElectrobunWorkerService'); + }); + + it('should expose the launch service as the named "launcher" export', async () => { + const { launcher } = await import('../src/index.js'); + expect(launcher).toBeTypeOf('function'); + expect(launcher.name).toBe('ElectrobunLaunchService'); + }); + + it('should re-export the cefRendererRequired helper', async () => { + const { cefRendererRequired } = await import('../src/index.js'); + expect(cefRendererRequired).toBeTypeOf('function'); + const err = cefRendererRequired('darwin'); + expect(err).toBeInstanceOf(Error); + expect(err.message).toContain('CEF renderer'); + }); + + it('should re-export the deeplinkUnsupportedOnPlatform helper', async () => { + const { deeplinkUnsupportedOnPlatform } = await import('../src/index.js'); + expect(deeplinkUnsupportedOnPlatform).toBeTypeOf('function'); + }); + + it('should re-export SevereServiceError', async () => { + const { SevereServiceError } = await import('../src/index.js'); + expect(SevereServiceError).toBeTypeOf('function'); + }); + + it('should expose the standalone session helpers', async () => { + const mod = await import('../src/index.js'); + expect(mod.startWdioSession).toBeTypeOf('function'); + expect(mod.cleanupWdioSession).toBeTypeOf('function'); + expect(mod.createElectrobunCapabilities).toBeTypeOf('function'); + }); +}); diff --git a/packages/electrobun-service/test/launcher.spec.ts b/packages/electrobun-service/test/launcher.spec.ts new file mode 100644 index 000000000..5f192b398 --- /dev/null +++ b/packages/electrobun-service/test/launcher.spec.ts @@ -0,0 +1,341 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SevereServiceError } from 'webdriverio'; + +import { cefRendererRequired } from '../src/errors.js'; + +const logMocks = vi.hoisted(() => ({ warn: vi.fn() })); +vi.mock('@wdio/native-utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createLogger: () => ({ trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: logMocks.warn, error: vi.fn() }), + }; +}); + +// Mock the IO-bound config helpers so the launcher matrix can be driven without +// a real bundle on disk. resolveElectrobunApp returns a fixed resolved app; +// verifyCefRenderer is spied so throws can be asserted. The launcher no longer +// pins the port itself — that now happens inside the (mocked) spawn path — but +// writeRemoteDebuggingPort is still mocked so we can assert the launcher never +// calls it directly. (Real behaviour is covered in electrobunConfig.spec.ts.) +vi.mock('../src/electrobunConfig.js', () => ({ + resolveElectrobunApp: vi.fn(() => ({ + binaryPath: '/apps/Demo.app/Contents/MacOS/Demo', + bundlePath: '/apps/Demo.app', + resourcesDir: '/apps/Demo.app/Contents/Resources', + buildJsonPath: '/apps/Demo.app/Contents/Resources/build.json', + identifier: 'com.example.demo', + })), + verifyCefRenderer: vi.fn(), + writeRemoteDebuggingPort: vi.fn(), +})); + +// Mock the native-mode spawn so no real process is launched (and no real bundle +// is cloned). The clone + port-pin now live inside spawnElectrobunApp. +vi.mock('../src/nativeMode.js', () => ({ + spawnElectrobunApp: vi.fn(() => ({ + proc: { pid: 4321, exitCode: null, signalCode: null, kill: vi.fn() }, + cleanupDirs: ['/tmp/wdio-electrobun-home-test', '/tmp/wdio-electrobun-bundle-test'], + port: 9333, + logHandlers: [], + })), + stopElectrobunApp: vi.fn().mockResolvedValue(undefined), + waitForCdpReady: vi.fn().mockResolvedValue(undefined), +})); + +import { resolveElectrobunApp, verifyCefRenderer, writeRemoteDebuggingPort } from '../src/electrobunConfig.js'; +import ElectrobunLaunchService from '../src/launcher.js'; +import { spawnElectrobunApp, stopElectrobunApp } from '../src/nativeMode.js'; +import type { ElectrobunCapabilities, ElectrobunServiceGlobalOptions } from '../src/types.js'; + +const baseConfig = {} as Parameters[0]; + +function makeLauncher(options: ElectrobunServiceGlobalOptions): ElectrobunLaunchService { + return new ElectrobunLaunchService(options, {} as ElectrobunCapabilities, baseConfig); +} + +// Native mode is macOS-only (0.x), so default every test to darwin; the platform-guard +// tests below override to linux/win32. Restored after each test. +const originalPlatform = process.platform; +function setPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value, writable: true, configurable: true }); +} + +describe('ElectrobunLaunchService', () => { + beforeEach(() => { + vi.clearAllMocks(); + setPlatform('darwin'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + setPlatform(originalPlatform); + }); + + describe('onPrepare — browser mode', () => { + it('should set browserName=chrome and return early when mode=browser', async () => { + const launcher = makeLauncher({ mode: 'browser', devServerUrl: 'http://localhost:3000' }); + const caps: ElectrobunCapabilities[] = [{ browserName: 'electrobun' }]; + + await launcher.onPrepare(baseConfig, caps); + + expect(caps[0].browserName).toBe('chrome'); + expect(vi.mocked(resolveElectrobunApp)).not.toHaveBeenCalled(); + }); + + it('should throw SevereServiceError when devServerUrl is missing in browser mode', async () => { + const launcher = makeLauncher({ mode: 'browser' }); + await expect(launcher.onPrepare(baseConfig, [{}])).rejects.toThrow(SevereServiceError); + }); + + it('should explain that devServerUrl is required in browser mode', async () => { + const launcher = makeLauncher({ mode: 'browser' }); + await expect(launcher.onPrepare(baseConfig, [{}])).rejects.toThrow(/devServerUrl is required/); + }); + + it('should throw SevereServiceError when devServerUrl is not a valid URL', async () => { + const launcher = makeLauncher({ mode: 'browser', devServerUrl: 'not-a-url' }); + await expect(launcher.onPrepare(baseConfig, [{}])).rejects.toThrow(/not a valid URL/); + }); + + it('should skip app spawn in onWorkerStart when in browser mode', async () => { + const launcher = makeLauncher({ mode: 'browser', devServerUrl: 'http://localhost:3000' }); + await launcher.onPrepare(baseConfig, [{ browserName: 'electrobun' }]); + + await launcher.onWorkerStart('0-0', [{ browserName: 'chrome' }]); + + expect(vi.mocked(spawnElectrobunApp)).not.toHaveBeenCalled(); + }); + + it('should reject a mixed browser-mode + native-mode capability set', async () => { + const launcher = makeLauncher({}); + const caps: ElectrobunCapabilities[] = [ + { 'wdio:electrobunServiceOptions': { mode: 'browser', devServerUrl: 'http://localhost:3000' } }, + { 'wdio:electrobunServiceOptions': { mode: 'native' } }, + ]; + + await expect(launcher.onPrepare(baseConfig, caps)).rejects.toThrow(/Mixed browser-mode and native-mode/); + }); + + it('should NOT apply the native-mode macOS guard in browser mode on Linux', async () => { + setPlatform('linux'); + const launcher = makeLauncher({ mode: 'browser', devServerUrl: 'http://localhost:3000' }); + const caps: ElectrobunCapabilities[] = [{ browserName: 'electrobun' }]; + + await launcher.onPrepare(baseConfig, caps); + + expect(caps[0].browserName).toBe('chrome'); + expect(vi.mocked(resolveElectrobunApp)).not.toHaveBeenCalled(); + }); + }); + + describe('onPrepare — native mode', () => { + it('should resolve, CEF-verify, and force browserName=chrome on each capability', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + const caps: ElectrobunCapabilities[] = [{ browserName: 'electrobun' }]; + + await launcher.onPrepare(baseConfig, caps); + + expect(vi.mocked(resolveElectrobunApp)).toHaveBeenCalledWith('/apps/Demo.app'); + expect(vi.mocked(verifyCefRenderer)).toHaveBeenCalledTimes(1); + expect(caps[0].browserName).toBe('chrome'); + }); + + it('should warn (not throw) when maxInstances > 1 — CEF is single-instance', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + const caps: ElectrobunCapabilities[] = [{ browserName: 'electrobun' }]; + + await launcher.onPrepare({ maxInstances: 2 } as Parameters[0], caps); + + expect(logMocks.warn).toHaveBeenCalledWith(expect.stringContaining('maxInstances')); + }); + + it('should not warn about maxInstances when it is 1', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + const caps: ElectrobunCapabilities[] = [{ browserName: 'electrobun' }]; + + await launcher.onPrepare({ maxInstances: 1 } as Parameters[0], caps); + + expect(logMocks.warn).not.toHaveBeenCalledWith(expect.stringContaining('maxInstances')); + }); + + it('should propagate a missing-appBinaryPath SevereServiceError from resolution', async () => { + vi.mocked(resolveElectrobunApp).mockImplementationOnce(() => { + throw new SevereServiceError('@wdio/electrobun-service requires an explicit appBinaryPath in native mode.'); + }); + const launcher = makeLauncher({}); + + await expect(launcher.onPrepare(baseConfig, [{}])).rejects.toThrow(/explicit appBinaryPath/); + }); + + it('should propagate cefRendererRequired when the app lacks the CEF renderer', async () => { + vi.mocked(verifyCefRenderer).mockImplementationOnce(() => { + throw cefRendererRequired('darwin'); + }); + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + + await expect(launcher.onPrepare(baseConfig, [{}])).rejects.toThrow(/CEF renderer/); + }); + + it('should resolve a capability-level appBinaryPath override', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Global.app' }); + const caps: ElectrobunCapabilities[] = [ + { 'wdio:electrobunServiceOptions': { appBinaryPath: '/apps/PerCap.app' } }, + ]; + + await launcher.onPrepare(baseConfig, caps); + + expect(vi.mocked(resolveElectrobunApp)).toHaveBeenCalledWith('/apps/PerCap.app'); + }); + + it('should fail fast with a SevereServiceError in native mode on Linux (CEF macOS-only)', async () => { + setPlatform('linux'); + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + + await expect(launcher.onPrepare(baseConfig, [{}])).rejects.toThrow(SevereServiceError); + // Fails fast — before touching the bundle. + expect(vi.mocked(resolveElectrobunApp)).not.toHaveBeenCalled(); + }); + + it('should explain native mode is macOS-only and cite the #317 follow-up on Windows', async () => { + setPlatform('win32'); + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + + const error = await launcher.onPrepare(baseConfig, [{}]).catch((e: Error) => e); + expect(error).toBeInstanceOf(SevereServiceError); + expect((error as Error).message).toMatch(/macOS/); + expect((error as Error).message).toContain('win32'); + expect((error as Error).message).toContain('issues/317'); + }); + }); + + describe('onWorkerStart — native mode', () => { + it('should allocate a port, spawn with the resolved app, and set debuggerAddress', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + + const cap: ElectrobunCapabilities = {}; + await launcher.onWorkerStart('0-0', [cap]); + + expect(vi.mocked(spawnElectrobunApp)).toHaveBeenCalledTimes(1); + const spawnArg = vi.mocked(spawnElectrobunApp).mock.calls[0][0]; + expect(spawnArg.app.bundlePath).toBe('/apps/Demo.app'); + expect(spawnArg.app.buildJsonPath).toBe('/apps/Demo.app/Contents/Resources/build.json'); + expect(typeof spawnArg.port).toBe('number'); + + expect(cap['goog:chromeOptions']).toEqual({ debuggerAddress: `127.0.0.1:${spawnArg.port}` }); + }); + + it('should NOT pin the port directly — clone + port-write happen inside the spawn path', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + + await launcher.onWorkerStart('0-0', [{}]); + + expect(vi.mocked(writeRemoteDebuggingPort)).not.toHaveBeenCalled(); + }); + + it('should allocate a distinct port + spawn per capability for multiremote', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}, {}]); + + const caps: ElectrobunCapabilities[] = [{}, {}]; + await launcher.onWorkerStart('0-0', caps); + + expect(vi.mocked(spawnElectrobunApp)).toHaveBeenCalledTimes(2); + const portA = vi.mocked(spawnElectrobunApp).mock.calls[0][0].port; + const portB = vi.mocked(spawnElectrobunApp).mock.calls[1][0].port; + expect(portA).not.toBe(portB); + expect((caps[0]['goog:chromeOptions'] as Record).debuggerAddress).toBe(`127.0.0.1:${portA}`); + expect((caps[1]['goog:chromeOptions'] as Record).debuggerAddress).toBe(`127.0.0.1:${portB}`); + }); + + it('should preserve existing goog:chromeOptions when setting debuggerAddress', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + + const cap: ElectrobunCapabilities = { 'goog:chromeOptions': { args: ['--headless'] } }; + await launcher.onWorkerStart('0-0', [cap]); + + const chromeOptions = cap['goog:chromeOptions'] as Record; + expect(chromeOptions.args).toEqual(['--headless']); + expect(chromeOptions.debuggerAddress).toMatch(/^127\.0\.0\.1:\d+$/); + }); + + it('should accept a single (non-array) capability', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + + const cap: ElectrobunCapabilities = {}; + await launcher.onWorkerStart('0-0', cap); + + expect(vi.mocked(spawnElectrobunApp)).toHaveBeenCalledTimes(1); + expect(cap['goog:chromeOptions']).toBeDefined(); + }); + + it('should throw SevereServiceError when no app was resolved (onPrepare skipped)', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + // Note: onPrepare intentionally NOT called. + await expect(launcher.onWorkerStart('0-0', [{}])).rejects.toThrow(/no resolved Electrobun app/); + }); + + it('should skip and warn when no capabilities are provided', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + + await expect(launcher.onWorkerStart('0-0', undefined)).resolves.toBeUndefined(); + expect(vi.mocked(spawnElectrobunApp)).not.toHaveBeenCalled(); + }); + }); + + describe('onComplete', () => { + it('should stop every spawned app and resolve cleanly', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + await launcher.onWorkerStart('0-0', [{}]); + + await expect(launcher.onComplete()).resolves.toBeUndefined(); + expect(vi.mocked(stopElectrobunApp)).toHaveBeenCalledTimes(1); + }); + + it('should resolve without error when no apps were spawned', async () => { + const launcher = makeLauncher({ mode: 'browser', devServerUrl: 'http://localhost:3000' }); + await launcher.onPrepare(baseConfig, [{}]); + + await expect(launcher.onComplete()).resolves.toBeUndefined(); + expect(vi.mocked(stopElectrobunApp)).not.toHaveBeenCalled(); + }); + + it('should swallow a stopElectrobunApp rejection and still resolve', async () => { + vi.mocked(stopElectrobunApp).mockRejectedValueOnce(new Error('kill failed')); + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + await launcher.onWorkerStart('0-0', [{}]); + + await expect(launcher.onComplete()).resolves.toBeUndefined(); + }); + }); + + describe('onWorkerEnd', () => { + it('should stop the worker app per-spec so apps do not accumulate', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + await launcher.onWorkerStart('0-0', [{}]); + + await expect(launcher.onWorkerEnd('0-0')).resolves.toBeUndefined(); + expect(vi.mocked(stopElectrobunApp)).toHaveBeenCalledTimes(1); + + // Already torn down — onComplete must not stop it again. + await launcher.onComplete(); + expect(vi.mocked(stopElectrobunApp)).toHaveBeenCalledTimes(1); + }); + + it('should resolve when the worker never spawned an app', async () => { + const launcher = makeLauncher({ appBinaryPath: '/apps/Demo.app' }); + await launcher.onPrepare(baseConfig, [{}]); + + await expect(launcher.onWorkerEnd('0-9')).resolves.toBeUndefined(); + expect(vi.mocked(stopElectrobunApp)).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/electrobun-service/test/mock.spec.ts b/packages/electrobun-service/test/mock.spec.ts new file mode 100644 index 000000000..7ae814794 --- /dev/null +++ b/packages/electrobun-service/test/mock.spec.ts @@ -0,0 +1,318 @@ +import vm from 'node:vm'; + +import type { CdpBridge } from '@wdio/electrobun-cdp-bridge'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock at the boundary — the CdpBridge — not the local modules. send() runs the +// emitted Runtime.evaluate expression in a Node vm sandbox holding a shared +// `window`, so the actual inner-recorder JS (innerRecorder.ts) is exercised +// end-to-end: install → record real calls → read-back → impl/return/clear/etc. +// (No CEF in unit tests; the in-webview round-trip itself is only fully proven +// against a real app — see the E2E-validation gap note in the PR.) +vi.mock('@wdio/electrobun-cdp-bridge', () => ({ CdpBridge: class {} })); + +import { createMock } from '../src/mock.js'; +import { ElectrobunMockStore } from '../src/mockStore.js'; + +interface FakeWindow { + __WDIO_ELECTROBUN_MOCKS__?: Record; + [key: string]: unknown; +} + +/** + * A bridge whose `send('Runtime.evaluate', { expression })` runs the expression + * against a persistent sandbox. The expression form the service emits is + * `(function(){ ... })()` / `(async()=>{...})()`, which `vm.runInContext` + * evaluates and returns. Results are JSON-cloned to mimic CDP returnByValue. + */ +function makeBridge(initialWindow: FakeWindow = {}): { + bridge: CdpBridge; + send: ReturnType; + window: FakeWindow; +} { + const window = initialWindow; + const sandbox: { window: FakeWindow } = { window }; + // Inside the page `window.x` and bare `x` are the same global; expose both. + vm.createContext(sandbox); + Object.defineProperty(sandbox, 'globalThis', { value: sandbox }); + + const send = vi.fn(async (method: string, params: { expression: string }) => { + if (method !== 'Runtime.evaluate') { + throw new Error(`unexpected CDP method ${method}`); + } + const value = vm.runInContext(params.expression, sandbox); + const resolved = await Promise.resolve(value); + // returnByValue semantics: serialise then revive. + const cloned = resolved === undefined ? undefined : JSON.parse(JSON.stringify(resolved)); + return { result: { value: cloned } }; + }); + + const bridge = { send } as unknown as CdpBridge; + return { bridge, send, window }; +} + +describe('createMock (Electrobun mocking)', () => { + let store: ElectrobunMockStore; + + beforeEach(() => { + store = new ElectrobunMockStore(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('install', () => { + it('should inject the inner recorder over the dotted target path on mock()', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 'real' } }); + + await createMock('api.fetchData', bridge, store); + + const reg = window.__WDIO_ELECTROBUN_MOCKS__ as Record; + expect(reg['api.fetchData']).toBeDefined(); + // The live function is now the spy, not the original. + expect((window.api as { fetchData: unknown }).fetchData).not.toBe(reg['api.fetchData'].original); + }); + + it('should register the outer mock in the store and flag it as an electrobun mock', async () => { + const { bridge } = makeBridge({ api: { fetchData: () => 1 } }); + + const mock = await createMock('api.fetchData', bridge, store); + + expect(store.getMock('api.fetchData')).toBe(mock); + expect((mock as unknown as { __isElectrobunMock: boolean }).__isElectrobunMock).toBe(true); + expect(mock.getMockName()).toBe('electrobun.api.fetchData'); + }); + + it('should reject a target that is not a valid dotted property path', async () => { + const { bridge, send } = makeBridge({ api: { fetchData: () => 1 } }); + + await expect(createMock("api['fetch-data']", bridge, store)).rejects.toThrow(/valid dotted property path/); + await expect(createMock("x'); doEvil(); //", bridge, store)).rejects.toThrow(/valid dotted property path/); + // Rejected before any script reaches the page. + expect(send).not.toHaveBeenCalled(); + }); + + it('should throw inside the page when the target is not a function', async () => { + const { bridge } = makeBridge({ api: { fetchData: 42 } }); + + await expect(createMock('api.fetchData', bridge, store)).rejects.toThrow(/is not a function/); + }); + + it('should return the existing handle (idempotent install) when re-mocking the same target', async () => { + const { bridge, send } = makeBridge({ api: { fetchData: () => 1 } }); + + const first = await createMock('api.fetchData', bridge, store); + send.mockClear(); + const second = await createMock('api.fetchData', bridge, store); + + expect(second).toBe(first); + // Install re-runs (idempotent in-page) but no second outer mock is built. + expect(store.getMocks()).toHaveLength(1); + }); + }); + + describe('update', () => { + it('should read inner call data back and populate the outer mock.calls/results', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 'real' } }); + const mock = await createMock('api.fetchData', bridge, store); + + // Drive real calls through the installed spy in the page. + const spy = (window.api as { fetchData: (...a: unknown[]) => unknown }).fetchData; + spy('a', 1); + spy('b', 2); + + await mock.update(); + + expect(mock.mock.calls).toEqual([ + ['a', 1], + ['b', 2], + ]); + expect(mock.mock.results).toHaveLength(2); + expect(mock.mock.invocationCallOrder).toHaveLength(2); + }); + + it('should replace outer state wholesale when the inner history shrinks', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + const spy = (window.api as { fetchData: (...a: unknown[]) => unknown }).fetchData; + + spy('x'); + await mock.update(); + expect(mock.mock.calls).toEqual([['x']]); + + await mock.mockClear(); + await mock.update(); + expect(mock.mock.calls).toEqual([]); + }); + }); + + describe('behaviour setters push to the inner recorder', () => { + it('should make the inner spy return the mockReturnValue', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 'real' } }); + const mock = await createMock('api.fetchData', bridge, store); + + await mock.mockReturnValue(99); + + const spy = (window.api as { fetchData: () => unknown }).fetchData; + expect(spy()).toBe(99); + }); + + it('should make the inner spy resolve the mockResolvedValue', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 'real' } }); + const mock = await createMock('api.fetchData', bridge, store); + + await mock.mockResolvedValue({ ok: true }); + + const spy = (window.api as { fetchData: () => Promise }).fetchData; + await expect(spy()).resolves.toEqual({ ok: true }); + }); + + it('should reconstruct an Error for mockRejectedValue inside the page', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 'real' } }); + const mock = await createMock('api.fetchData', bridge, store); + + await mock.mockRejectedValue(new Error('boom')); + + const spy = (window.api as { fetchData: () => Promise }).fetchData; + await expect(spy()).rejects.toThrow(/boom/); + }); + + it('should preserve the Error name and stack through reconstruction', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 'real' } }); + const mock = await createMock('api.fetchData', bridge, store); + const original = new TypeError('bad shape'); + original.stack = 'TypeError: bad shape\n at original (api.ts:1:1)'; + + await mock.mockRejectedValue(original); + + const spy = (window.api as { fetchData: () => Promise }).fetchData; + const rejected = await spy().then( + () => undefined, + (e: Error) => e, + ); + expect(rejected?.name).toBe('TypeError'); + expect(rejected?.stack).toBe(original.stack); + }); + + it('should run the pushed mockImplementation in the page', async () => { + const { bridge, window } = makeBridge({ api: { add: () => 0 } }); + const mock = await createMock('api.add', bridge, store); + + await mock.mockImplementation((...args: unknown[]) => (args[0] as number) + (args[1] as number)); + + const spy = (window.api as { add: (...a: unknown[]) => unknown }).add; + expect(spy(2, 3)).toBe(5); + }); + + it('should label in-page failures with the mock context, not execute', async () => { + const failing = { + send: vi.fn(async () => ({ exceptionDetails: { text: 'boom' } })), + } as unknown as CdpBridge; + + await expect(createMock('api.fetchData', failing, store)).rejects.toThrow( + 'browser.electrobun.mock("api.fetchData") failed: boom', + ); + }); + + it('should reject a native function passed as a mockImplementation', async () => { + const { bridge } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + + await expect(mock.mockImplementation(Array.prototype.push as (...args: unknown[]) => unknown)).rejects.toThrow( + /native or bound functions/, + ); + }); + + it('should round-trip a mockReturnValue containing U+2028/U+2029', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + + await mock.mockReturnValue('a\u2028b\u2029c'); + + const spy = (window.api as { fetchData: () => unknown }).fetchData; + expect(spy()).toBe('a\u2028b\u2029c'); + }); + + it('should round-trip a mockRejectedValue whose message contains U+2028', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + + await mock.mockRejectedValue(new Error('line\u2028break')); + + const spy = (window.api as { fetchData: () => Promise }).fetchData; + const rejected = await spy().then( + () => undefined, + (e: Error) => e, + ); + expect(rejected?.message).toBe('line\u2028break'); + }); + + it('should reject a method shorthand passed as a mockImplementation', async () => { + const { bridge } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + const shorthand = { + fetch(x: unknown) { + return x; + }, + }.fetch; + + await expect(mock.mockImplementation(shorthand as (...args: unknown[]) => unknown)).rejects.toThrow( + /method shorthands/, + ); + }); + + it('should reject a function passed as a mockReturnValue (not JSON-serialisable)', async () => { + const { bridge } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + + await expect(mock.mockReturnValue(() => 1)).rejects.toThrow(/not JSON-serialisable/); + }); + }); + + describe('lifecycle', () => { + it('should clear inner + outer call history on mockClear', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + const spy = (window.api as { fetchData: (...a: unknown[]) => unknown }).fetchData; + + spy('x'); + await mock.update(); + expect(mock.mock.calls).toHaveLength(1); + + await mock.mockClear(); + expect(mock.mock.calls).toHaveLength(0); + // Inner cleared too: a fresh read shows no calls. + await mock.update(); + expect(mock.mock.calls).toHaveLength(0); + }); + + it('should reset the inner implementation on mockReset and keep the mock name', async () => { + const { bridge, window } = makeBridge({ api: { fetchData: () => 1 } }); + const mock = await createMock('api.fetchData', bridge, store); + + await mock.mockReturnValue(7); + const spy = (window.api as { fetchData: () => unknown }).fetchData; + expect(spy()).toBe(7); + + await mock.mockReset(); + // Default behaviour after reset is undefined. + expect(spy()).toBeUndefined(); + expect(mock.getMockName()).toBe('electrobun.api.fetchData'); + }); + + it('should restore the original function and drop the store entry on mockRestore', async () => { + const original = (): string => 'real'; + const { bridge, window } = makeBridge({ api: { fetchData: original } }); + const mock = await createMock('api.fetchData', bridge, store); + + expect((window.api as { fetchData: unknown }).fetchData).not.toBe(original); + + await mock.mockRestore(); + + expect((window.api as { fetchData: unknown }).fetchData).toBe(original); + expect(store.getMock('api.fetchData')).toBeUndefined(); + expect(window.__WDIO_ELECTROBUN_MOCKS__?.['api.fetchData']).toBeUndefined(); + }); + }); +}); diff --git a/packages/electrobun-service/test/nativeMode.spec.ts b/packages/electrobun-service/test/nativeMode.spec.ts new file mode 100644 index 000000000..1ec29aab9 --- /dev/null +++ b/packages/electrobun-service/test/nativeMode.spec.ts @@ -0,0 +1,450 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SevereServiceError } from 'webdriverio'; + +const spawnMock = vi.fn(); +const execFileSyncMock = vi.fn(); +const mkdtempSyncMock = vi.fn(); +const cpSyncMock = vi.fn(); +const rmSyncMock = vi.fn(); +const createLogCaptureMock = vi.fn(); +const writeRemoteDebuggingPortMock = vi.fn(); + +vi.mock('node:child_process', () => ({ + spawn: (...args: unknown[]) => spawnMock(...args), + execFileSync: (...args: unknown[]) => execFileSyncMock(...args), +})); + +vi.mock('node:fs', () => ({ + mkdtempSync: (...args: unknown[]) => mkdtempSyncMock(...args), + cpSync: (...args: unknown[]) => cpSyncMock(...args), + rmSync: (...args: unknown[]) => rmSyncMock(...args), +})); + +vi.mock('@wdio/native-core', () => ({ + createLogCapture: (...args: unknown[]) => createLogCaptureMock(...args), +})); + +vi.mock('../src/electrobunConfig.js', () => ({ + writeRemoteDebuggingPort: (...args: unknown[]) => writeRemoteDebuggingPortMock(...args), +})); + +import type { ResolvedElectrobunApp } from '../src/electrobunConfig.js'; +import { cloneAppBundle, spawnElectrobunApp, stopElectrobunApp, waitForCdpReady } from '../src/nativeMode.js'; +import type { ElectrobunServiceOptions } from '../src/types.js'; + +interface FakeProc extends EventEmitter { + pid: number; + exitCode: number | null; + signalCode: string | null; + stdout: EventEmitter | null; + stderr: EventEmitter | null; + kill: ReturnType; +} + +function makeFakeProc(): FakeProc { + const proc = new EventEmitter() as FakeProc; + proc.pid = 4321; + proc.exitCode = null; + proc.signalCode = null; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + return proc; +} + +const APP: ResolvedElectrobunApp = { + binaryPath: '/apps/Demo.app/Contents/MacOS/Demo', + bundlePath: '/apps/Demo.app', + resourcesDir: '/apps/Demo.app/Contents/Resources', + buildJsonPath: '/apps/Demo.app/Contents/Resources/build.json', + identifier: 'com.example.demo', +}; + +const CLONE_PARENT = '/tmp/wdio-electrobun-bundle-xyz'; +const USER_HOME = '/tmp/wdio-electrobun-home-abc'; + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +// These suites mock setPlatform('darwin') to exercise the macOS clone/spawn paths, but +// node:path uses the RUNNER's separator regardless — so their hardcoded POSIX path +// assertions can't match on a Windows runner (the logic is OS-identical since the +// platform is mocked, and is covered on Linux/macOS; real Windows behaviour is exercised +// by the e2e suite). Skip on win32 rather than re-assert every path per separator. +// Aliased to describe.skip (not describe.skipIf) so vitest/valid-describe-callback +// doesn't trip on a curried describe modifier with no name/callback. +const describePosixPaths = process.platform === 'win32' ? describe.skip : describe; + +describe('nativeMode', () => { + let proc: FakeProc; + + beforeEach(() => { + vi.clearAllMocks(); + proc = makeFakeProc(); + spawnMock.mockReturnValue(proc); + // mkdtempSync is called with distinct prefixes for the bundle clone vs the + // the per-run --user-data-dir; key the stub off the prefix so call order doesn't matter. + mkdtempSyncMock.mockImplementation((prefix: string) => (prefix.includes('bundle') ? CLONE_PARENT : USER_HOME)); + createLogCaptureMock.mockReturnValue({ close: vi.fn() }); + setPlatform('darwin'); + }); + + afterEach(() => { + setPlatform(originalPlatform); + vi.useRealTimers(); + }); + + describePosixPaths('cloneAppBundle', () => { + it('should use the APFS clonefile (cp -Rc) on darwin', () => { + setPlatform('darwin'); + + const result = cloneAppBundle('/apps/Demo.app'); + + expect(execFileSyncMock).toHaveBeenCalledWith('cp', [ + '-Rc', + '/apps/Demo.app', + '/tmp/wdio-electrobun-bundle-xyz/Demo.app', + ]); + expect(cpSyncMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + cloneParentDir: CLONE_PARENT, + clonedBundlePath: '/tmp/wdio-electrobun-bundle-xyz/Demo.app', + }); + }); + + it('should fall back to a recursive cpSync when the APFS clone fails', () => { + setPlatform('darwin'); + execFileSyncMock.mockImplementationOnce(() => { + throw new Error('clonefile unsupported on this volume'); + }); + + const result = cloneAppBundle('/apps/Demo.app'); + + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + // The failed cp -Rc may have left a partial tree — it must be cleared before the + // cpSync fallback so the copy never merges onto a half-written destination. + const rmOrder = rmSyncMock.mock.invocationCallOrder[0]; + const cpOrder = cpSyncMock.mock.invocationCallOrder[0]; + expect(rmSyncMock).toHaveBeenCalledWith('/tmp/wdio-electrobun-bundle-xyz/Demo.app', { + recursive: true, + force: true, + }); + expect(rmOrder).toBeLessThan(cpOrder); + expect(cpSyncMock).toHaveBeenCalledWith('/apps/Demo.app', '/tmp/wdio-electrobun-bundle-xyz/Demo.app', { + recursive: true, + }); + expect(result.clonedBundlePath).toBe('/tmp/wdio-electrobun-bundle-xyz/Demo.app'); + }); + + it('should use cpSync (not cp -Rc) on non-darwin platforms', () => { + setPlatform('linux'); + + cloneAppBundle('/apps/Demo'); + + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(cpSyncMock).toHaveBeenCalledWith('/apps/Demo', '/tmp/wdio-electrobun-bundle-xyz/Demo', { + recursive: true, + }); + }); + + it('should remove the empty temp parent dir if the copy throws (no leak)', () => { + setPlatform('linux'); + cpSyncMock.mockImplementationOnce(() => { + throw new Error('ENOSPC: no space left on device'); + }); + + expect(() => cloneAppBundle('/apps/Demo')).toThrow('ENOSPC'); + expect(rmSyncMock).toHaveBeenCalledWith('/tmp/wdio-electrobun-bundle-xyz', { recursive: true, force: true }); + }); + }); + + describePosixPaths('spawnElectrobunApp', () => { + it('should clone the bundle and pin the port into the CLONE build.json (not the original)', () => { + spawnElectrobunApp({ + app: APP, + appArgs: ['--flag'], + port: 9333, + options: {} as ElectrobunServiceOptions, + }); + + expect(execFileSyncMock).toHaveBeenCalledWith('cp', [ + '-Rc', + '/apps/Demo.app', + '/tmp/wdio-electrobun-bundle-xyz/Demo.app', + ]); + expect(writeRemoteDebuggingPortMock).toHaveBeenCalledTimes(1); + const [buildJsonPath, port] = writeRemoteDebuggingPortMock.mock.calls[0]; + expect(buildJsonPath).toBe('/tmp/wdio-electrobun-bundle-xyz/Demo.app/Contents/Resources/build.json'); + expect(buildJsonPath).not.toBe(APP.buildJsonPath); + expect(port).toBe(9333); + }); + + it('should remove the clone if pinning the port throws (no temp-dir leak)', () => { + writeRemoteDebuggingPortMock.mockImplementationOnce(() => { + throw new Error('EACCES: build.json not writable'); + }); + + expect(() => + spawnElectrobunApp({ app: APP, appArgs: [], port: 9333, options: {} as ElectrobunServiceOptions }), + ).toThrow('EACCES'); + + expect(rmSyncMock).toHaveBeenCalledWith(CLONE_PARENT, { recursive: true, force: true }); + }); + + it('should spawn the CLONED binary and pin the port WITHOUT a separate --user-data-dir', () => { + const result = spawnElectrobunApp({ + app: APP, + appArgs: ['--flag'], + port: 9333, + options: {} as ElectrobunServiceOptions, + }); + + const [binary, args] = spawnMock.mock.calls[0] as [string, string[]]; + expect(binary).toBe('/tmp/wdio-electrobun-bundle-xyz/Demo.app/Contents/MacOS/Demo'); + expect(args).toEqual(['--flag']); + // No --user-data-dir: CEF uses its own root_cache_path so the persist:default + // partition profile is created inside the profile dir (avoids the catch-22). + expect(writeRemoteDebuggingPortMock).toHaveBeenCalledWith( + '/tmp/wdio-electrobun-bundle-xyz/Demo.app/Contents/Resources/build.json', + 9333, + ); + expect(result.cleanupDirs).toEqual([CLONE_PARENT]); + }); + + it('should wrap the spawn in xvfb-run on Linux (headless CI needs an X display)', () => { + setPlatform('linux'); + + spawnElectrobunApp({ app: APP, appArgs: ['--flag'], port: 9333, options: {} as ElectrobunServiceOptions }); + + const [command, args] = spawnMock.mock.calls[0] as [string, string[]]; + expect(command).toBe('xvfb-run'); + expect(args).toEqual(['-a', '/tmp/wdio-electrobun-bundle-xyz/Demo.app/Contents/MacOS/Demo', '--flag']); + }); + + it('should take the cpSync fallback when the APFS clone fails', () => { + execFileSyncMock.mockImplementationOnce(() => { + throw new Error('clonefile unsupported'); + }); + + spawnElectrobunApp({ + app: APP, + appArgs: [], + port: 9333, + options: {} as ElectrobunServiceOptions, + }); + + expect(cpSyncMock).toHaveBeenCalledWith('/apps/Demo.app', '/tmp/wdio-electrobun-bundle-xyz/Demo.app', { + recursive: true, + }); + const [binary] = spawnMock.mock.calls[0] as [string]; + expect(binary).toBe('/tmp/wdio-electrobun-bundle-xyz/Demo.app/Contents/MacOS/Demo'); + }); + + it('should clone with cpSync on non-darwin and spawn the cloned binary', () => { + setPlatform('linux'); + const linuxApp: ResolvedElectrobunApp = { + binaryPath: '/apps/Demo/Demo', + bundlePath: '/apps/Demo', + resourcesDir: '/apps/Demo', + buildJsonPath: '/apps/Demo/build.json', + }; + + spawnElectrobunApp({ + app: linuxApp, + appArgs: [], + port: 9333, + options: {} as ElectrobunServiceOptions, + }); + + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(writeRemoteDebuggingPortMock).toHaveBeenCalledWith( + '/tmp/wdio-electrobun-bundle-xyz/Demo/build.json', + 9333, + ); + // On Linux the cloned binary is spawned under xvfb-run (headless CI display). + const [command, args] = spawnMock.mock.calls[0] as [string, string[]]; + expect(command).toBe('xvfb-run'); + expect(args).toEqual(['-a', '/tmp/wdio-electrobun-bundle-xyz/Demo/Demo']); + }); + + it('should throw a SevereServiceError when the app has no build.json path', () => { + const noBuildJson = { ...APP, buildJsonPath: undefined as unknown as string }; + + expect(() => + spawnElectrobunApp({ + app: noBuildJson, + appArgs: [], + port: 9333, + options: {} as ElectrobunServiceOptions, + }), + ).toThrow(SevereServiceError); + expect(spawnMock).not.toHaveBeenCalled(); + expect(writeRemoteDebuggingPortMock).not.toHaveBeenCalled(); + }); + + it('should merge user-supplied env over process.env', () => { + spawnElectrobunApp({ + app: APP, + appArgs: [], + port: 9333, + options: { env: { MY_FLAG: 'on' } } as ElectrobunServiceOptions, + }); + + const opts = spawnMock.mock.calls[0][2] as { env: NodeJS.ProcessEnv }; + expect(opts.env.MY_FLAG).toBe('on'); + }); + + it('should not wire log capture when captureBackendLogs is off', () => { + spawnElectrobunApp({ + app: APP, + appArgs: [], + port: 9333, + options: { captureBackendLogs: false } as ElectrobunServiceOptions, + }); + + expect(createLogCaptureMock).not.toHaveBeenCalled(); + }); + + it('should wire stdout + stderr log capture when captureBackendLogs is on', () => { + const result = spawnElectrobunApp({ + app: APP, + appArgs: [], + port: 9333, + options: { captureBackendLogs: true } as ElectrobunServiceOptions, + }); + + expect(createLogCaptureMock).toHaveBeenCalledTimes(2); + expect(result.logHandlers).toHaveLength(2); + }); + + it('should not throw when the process emits a post-spawn error', () => { + spawnElectrobunApp({ + app: APP, + appArgs: [], + port: 9333, + options: {} as ElectrobunServiceOptions, + }); + + expect(() => proc.emit('error', new Error('ENOENT'))).not.toThrow(); + }); + }); + + describePosixPaths('stopElectrobunApp', () => { + it('should close log handlers, SIGTERM the live process, and remove every cleanup dir', async () => { + const handler = { close: vi.fn() }; + // Process exits promptly after SIGTERM. + proc.kill.mockImplementation(() => { + proc.exitCode = 0; + return true; + }); + + await stopElectrobunApp({ + proc: proc as unknown as import('node:child_process').ChildProcess, + cleanupDirs: [USER_HOME, CLONE_PARENT], + port: 9333, + logHandlers: [handler as unknown as import('node:readline').Interface], + }); + + expect(handler.close).toHaveBeenCalled(); + expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); + expect(rmSyncMock).toHaveBeenCalledWith(USER_HOME, { recursive: true, force: true }); + expect(rmSyncMock).toHaveBeenCalledWith(CLONE_PARENT, { recursive: true, force: true }); + }); + + it('should SIGKILL after the grace window and wait for the reap before removing dirs', async () => { + vi.useFakeTimers(); + try { + proc.kill.mockImplementation((signal: string) => { + if (signal === 'SIGKILL') { + // OS reaps the process shortly after the kill. + setTimeout(() => { + proc.signalCode = 'SIGKILL'; + }, 300); + } + return true; + }); + + const stopPromise = stopElectrobunApp({ + proc: proc as unknown as import('node:child_process').ChildProcess, + cleanupDirs: [USER_HOME, CLONE_PARENT], + port: 9333, + logHandlers: [], + }); + + await vi.advanceTimersByTimeAsync(5_000); // SIGTERM grace expires + expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); + // The reap-wait must hold rmSync until the process is gone. + expect(rmSyncMock).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(400); // reap fires at +300 + await stopPromise; + expect(rmSyncMock).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('should skip killing an already-exited process but still remove the cleanup dirs', async () => { + proc.exitCode = 0; + + await stopElectrobunApp({ + proc: proc as unknown as import('node:child_process').ChildProcess, + cleanupDirs: [USER_HOME, CLONE_PARENT], + port: 9333, + logHandlers: [], + }); + + expect(proc.kill).not.toHaveBeenCalled(); + expect(rmSyncMock).toHaveBeenCalledTimes(2); + }); + + it('should keep removing remaining dirs when one removal fails', async () => { + proc.exitCode = 0; + rmSyncMock.mockImplementationOnce(() => { + throw new Error('EBUSY'); + }); + + await expect( + stopElectrobunApp({ + proc: proc as unknown as import('node:child_process').ChildProcess, + cleanupDirs: [USER_HOME, CLONE_PARENT], + port: 9333, + logHandlers: [], + }), + ).resolves.toBeUndefined(); + + expect(rmSyncMock).toHaveBeenCalledTimes(2); + }); + }); + + describe('waitForCdpReady', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('should resolve once /json reports a page target', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => [{ type: 'page' }] }); + vi.stubGlobal('fetch', fetchMock); + + await expect(waitForCdpReady(9333, 1000)).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:9333/json', expect.anything()); + }); + + it('should resolve (not throw) on timeout when no page target ever appears', async () => { + // /json responds but never lists a page target — should warn-and-proceed, not hang/throw. + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })); + + await expect(waitForCdpReady(9333, 50)).resolves.toBeUndefined(); + }); + + it('should resolve (not throw) when the endpoint never responds', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + + await expect(waitForCdpReady(9333, 50)).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/electrobun-service/test/service.spec.ts b/packages/electrobun-service/test/service.spec.ts new file mode 100644 index 000000000..7d5279d33 --- /dev/null +++ b/packages/electrobun-service/test/service.spec.ts @@ -0,0 +1,347 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const connectMock = vi.fn().mockResolvedValue(undefined); +const sendMock = vi.fn(); +const switchTargetMock = vi.fn().mockResolvedValue(undefined); +const listWindowsMock = vi.fn().mockReturnValue(['main', 'second']); +const targetsMock = vi.fn().mockReturnValue([ + { id: 't-main', label: 'main', url: 'views://mainview/index.html', webSocketDebuggerUrl: 'ws://x/main' }, + { id: 't-2', label: 'window-1', url: 'views://secondview/index.html', webSocketDebuggerUrl: 'ws://x/2' }, +]); +const closeMock = vi.fn().mockResolvedValue(undefined); +const cdpBridgeCtor = vi.fn(); + +vi.mock('@wdio/electrobun-cdp-bridge', () => ({ + CdpBridge: class { + constructor(opts: unknown) { + cdpBridgeCtor(opts); + } + connect = connectMock; + send = sendMock; + switchTarget = switchTargetMock; + listWindows = listWindowsMock; + listTargets = targetsMock; + activeLabel = 'main'; + close = closeMock; + }, +})); + +import type { ElectrobunServiceAPI } from '@wdio/native-types'; +import ElectrobunWorkerService from '../src/service.js'; + +type Installed = { electrobun: ElectrobunServiceAPI }; + +function nativeCap(port = 9333): Record { + return { 'goog:chromeOptions': { debuggerAddress: `localhost:${port}` } }; +} + +function makeBrowser( + windows: { handle: string; url: string }[] = [ + { handle: 'win-blank', url: 'about:blank' }, + { handle: 'win-main', url: 'views://mainview/index.html' }, + ], +): WebdriverIO.Browser { + let current = windows[0]?.handle; + return { + isMultiremote: false, + sessionId: 'abc', + getWindowHandles: vi.fn().mockResolvedValue(windows.map((w) => w.handle)), + getWindowHandle: vi.fn(async () => current), + switchToWindow: vi.fn(async (handle: string) => { + current = handle; + }), + getUrl: vi.fn(async () => windows.find((w) => w.handle === current)?.url ?? ''), + } as unknown as WebdriverIO.Browser; +} + +describe('ElectrobunWorkerService', () => { + beforeEach(() => { + vi.clearAllMocks(); + connectMock.mockResolvedValue(undefined); + sendMock.mockResolvedValue({ result: { value: undefined } }); + listWindowsMock.mockReturnValue(['main', 'second']); + targetsMock.mockReturnValue([ + { id: 't-main', label: 'main', url: 'views://mainview/index.html', webSocketDebuggerUrl: 'ws://x/main' }, + { id: 't-2', label: 'window-1', url: 'views://secondview/index.html', webSocketDebuggerUrl: 'ws://x/2' }, + ]); + closeMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('before() — native mode', () => { + it('should attach the CDP bridge using the capability debuggerAddress', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + + await service.before(nativeCap(9350), [], browser); + + expect(cdpBridgeCtor).toHaveBeenCalledTimes(1); + expect(cdpBridgeCtor.mock.calls[0][0]).toMatchObject({ host: 'localhost', port: 9350 }); + expect(connectMock).toHaveBeenCalledTimes(1); + }); + + it('should focus the WebDriver session on the content window, not a blank shell', async () => { + const browser = makeBrowser([ + { handle: 'win-blank', url: 'about:blank' }, + { handle: 'win-main', url: 'views://mainview/index.html' }, + ]); + const service = new ElectrobunWorkerService({}, {}); + + await service.before(nativeCap(), [], browser); + + // Ends on the content window so $/click target the app, not about:blank. + expect(vi.mocked(browser.switchToWindow).mock.calls.at(-1)?.[0]).toBe('win-main'); + }); + + it('should install browser.electrobun with the full API surface', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + + await service.before(nativeCap(), [], browser); + + const { electrobun } = browser as unknown as Installed; + expect(electrobun).toBeDefined(); + for (const name of [ + 'execute', + 'switchWindow', + 'listWindows', + 'mock', + 'isMockFunction', + 'clearAllMocks', + 'resetAllMocks', + 'restoreAllMocks', + 'triggerDeeplink', + ]) { + expect(typeof (electrobun as unknown as Record)[name]).toBe('function'); + } + }); + + it('should not attach a bridge when no debuggerAddress is present', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + + await service.before({}, [], browser); + + expect(cdpBridgeCtor).not.toHaveBeenCalled(); + expect((browser as unknown as Partial).electrobun).toBeUndefined(); + }); + }); + + describe('before() — browser mode', () => { + it('should skip CDP attach in browser mode', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({ mode: 'browser', devServerUrl: 'http://localhost:3000' }, {}); + + await service.before(nativeCap(), [], browser); + + expect(cdpBridgeCtor).not.toHaveBeenCalled(); + expect((browser as unknown as Partial).electrobun).toBeUndefined(); + }); + + it('should read browser mode from capability-level options', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService( + {}, + { 'wdio:electrobunServiceOptions': { mode: 'browser', devServerUrl: 'http://localhost:4000' } }, + ); + + await service.before(nativeCap(), [], browser); + + expect(cdpBridgeCtor).not.toHaveBeenCalled(); + }); + + it('should NOT skip the attach for a stray devServerUrl without mode: browser', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({ devServerUrl: 'http://localhost:3000' }, {}); + + await service.before(nativeCap(), [], browser); + + // The launcher would have spawned natively (its criterion is mode only) — + // the worker must attach rather than silently leave the surface uninstalled. + expect(cdpBridgeCtor).toHaveBeenCalled(); + }); + }); + + describe('execute', () => { + it('should evaluate a function in the active target via Runtime.evaluate and return its value', async () => { + sendMock.mockResolvedValueOnce({ result: { value: 42 } }); + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + const result = await (browser as unknown as Installed).electrobun.execute(() => 42); + + expect(result).toBe(42); + const [method, params] = sendMock.mock.calls[0]; + expect(method).toBe('Runtime.evaluate'); + expect(params).toMatchObject({ returnByValue: true, awaitPromise: true }); + expect(params.expression).toContain('__WDIO_ELECTROBUN__'); + }); + + it('should wrap a raw string expression so its value is returned', async () => { + sendMock.mockResolvedValueOnce({ result: { value: 'ok' } }); + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + const result = await (browser as unknown as Installed).electrobun.execute('1 + 1'); + + expect(result).toBe('ok'); + expect(sendMock.mock.calls[0][1].expression).toBe('(async function () { return (1 + 1); })()'); + }); + + it('should throw when Runtime.evaluate reports exceptionDetails', async () => { + sendMock.mockResolvedValueOnce({ exceptionDetails: { exception: { description: 'boom' } } }); + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + await expect((browser as unknown as Installed).electrobun.execute(() => 1)).rejects.toThrow(/boom/); + }); + }); + + describe('switchWindow / listWindows', () => { + it('should delegate switchWindow to bridge.switchTarget', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + await (browser as unknown as Installed).electrobun.switchWindow('second'); + + expect(switchTargetMock).toHaveBeenCalledWith('second'); + }); + + it('should also sync the WebDriver session window on switchWindow', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + vi.mocked(browser.getWindowHandles).mockClear(); + vi.mocked(browser.switchToWindow).mockClear(); + + await (browser as unknown as Installed).electrobun.switchWindow('window-1'); + + // Not just the bridge target — $/click must follow, so the session re-syncs. + expect(switchTargetMock).toHaveBeenCalledWith('window-1'); + expect(browser.getWindowHandles).toHaveBeenCalled(); + expect(browser.switchToWindow).toHaveBeenCalled(); + }); + + it('should delegate listWindows to bridge.listWindows', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + const windows = await (browser as unknown as Installed).electrobun.listWindows(); + + expect(windows).toEqual(['main', 'second']); + }); + }); + + describe('mock surface (wired over the bridge)', () => { + // Detailed inner-recorder semantics live in test/mock.spec.ts / allMocks.spec.ts; + // here we assert installApi wires the family onto the bridge-backed store. + it('should install a recorder via Runtime.evaluate and return an electrobun mock', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + const { electrobun } = browser as unknown as Installed; + + const mock = await electrobun.mock('api.fetchData'); + + expect((mock as unknown as { __isElectrobunMock: boolean }).__isElectrobunMock).toBe(true); + expect(mock.getMockName()).toBe('electrobun.api.fetchData'); + const [method, params] = sendMock.mock.calls[0]; + expect(method).toBe('Runtime.evaluate'); + expect(params.expression).toContain('__WDIO_ELECTROBUN_MOCKS__'); + expect(params.expression).toContain('api.fetchData'); + }); + + it('should report mocks via isMockFunction and resolve clear/reset/restore-all', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + const { electrobun } = browser as unknown as Installed; + + const mock = await electrobun.mock('api.fetchData'); + expect(electrobun.isMockFunction(mock)).toBe(true); + expect(electrobun.isMockFunction('api.fetchData')).toBe(true); + expect(electrobun.isMockFunction(() => undefined)).toBe(false); + + await expect(electrobun.clearAllMocks()).resolves.toBeUndefined(); + await expect(electrobun.resetAllMocks()).resolves.toBeUndefined(); + await expect(electrobun.restoreAllMocks()).resolves.toBeUndefined(); + }); + + it('should keep a separate mock store per multiremote instance', async () => { + const instanceA = {} as WebdriverIO.Browser; + const instanceB = {} as WebdriverIO.Browser; + const mrBrowser = { + isMultiremote: true, + instances: ['browserA', 'browserB'], + getInstance: (name: string) => (name === 'browserA' ? instanceA : instanceB), + } as unknown as WebdriverIO.MultiRemoteBrowser; + const caps = { browserA: { capabilities: nativeCap(9361) }, browserB: { capabilities: nativeCap(9362) } }; + + const service = new ElectrobunWorkerService({}, {}); + await service.before(caps, [], mrBrowser); + + const electrobunA = (instanceA as unknown as Installed).electrobun; + const electrobunB = (instanceB as unknown as Installed).electrobun; + await electrobunA.mock('api.only'); + expect(electrobunA.isMockFunction('api.only')).toBe(true); + expect(electrobunB.isMockFunction('api.only')).toBe(false); + }); + // triggerDeeplink is now real (macOS) — covered in test/triggerDeeplink.spec.ts. + }); + + describe('teardown', () => { + it('should close the bridge on after() and afterSession()', async () => { + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + await service.after(); + expect(closeMock).toHaveBeenCalledTimes(1); + + // after() emptied the bridge list, so afterSession() has nothing more to close. + await service.afterSession(); + expect(closeMock).toHaveBeenCalledTimes(1); + }); + + it('should tolerate a bridge.close() rejection', async () => { + closeMock.mockRejectedValueOnce(new Error('socket gone')); + const browser = makeBrowser(); + const service = new ElectrobunWorkerService({}, {}); + await service.before(nativeCap(), [], browser); + + await expect(service.after()).resolves.toBeUndefined(); + }); + }); + + describe('multiremote', () => { + it('should attach a bridge per instance', async () => { + const instanceA = {} as WebdriverIO.Browser; + const instanceB = {} as WebdriverIO.Browser; + const mrBrowser = { + isMultiremote: true, + instances: ['browserA', 'browserB'], + getInstance: (name: string) => (name === 'browserA' ? instanceA : instanceB), + } as unknown as WebdriverIO.MultiRemoteBrowser; + + const caps = { + browserA: { capabilities: nativeCap(9341) }, + browserB: { capabilities: nativeCap(9342) }, + }; + + const service = new ElectrobunWorkerService({}, {}); + await service.before(caps, [], mrBrowser); + + expect(cdpBridgeCtor).toHaveBeenCalledTimes(2); + expect((instanceA as unknown as Partial).electrobun).toBeDefined(); + expect((instanceB as unknown as Partial).electrobun).toBeDefined(); + }); + }); +}); diff --git a/packages/electrobun-service/test/session.spec.ts b/packages/electrobun-service/test/session.spec.ts new file mode 100644 index 000000000..1e1bd441d --- /dev/null +++ b/packages/electrobun-service/test/session.spec.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const onPrepareMock = vi.fn().mockResolvedValue(undefined); +const onWorkerStartMock = vi.fn().mockResolvedValue(undefined); +const onCompleteMock = vi.fn().mockResolvedValue(undefined); +const serviceBeforeMock = vi.fn().mockResolvedValue(undefined); +const serviceAfterMock = vi.fn().mockResolvedValue(undefined); +const serviceAfterSessionMock = vi.fn().mockResolvedValue(undefined); +const remoteMock = vi.fn(); +const deleteSessionMock = vi.fn().mockResolvedValue(undefined); +const serviceCtorArgs: unknown[][] = []; + +vi.mock('../src/launcher.js', () => ({ + default: class { + onPrepare = onPrepareMock; + onWorkerStart = onWorkerStartMock; + onComplete = onCompleteMock; + }, +})); + +vi.mock('../src/service.js', () => ({ + default: class { + before = serviceBeforeMock; + after = serviceAfterMock; + afterSession = serviceAfterSessionMock; + constructor(...args: unknown[]) { + serviceCtorArgs.push(args); + } + }, +})); + +vi.mock('webdriverio', () => ({ + remote: (...args: unknown[]) => remoteMock(...args), +})); + +import { cleanup, createElectrobunCapabilities, init } from '../src/session.js'; + +function makeBrowser(): WebdriverIO.Browser { + return { sessionId: 'sess-1', deleteSession: deleteSessionMock } as unknown as WebdriverIO.Browser; +} + +describe('session', () => { + beforeEach(() => { + vi.clearAllMocks(); + serviceCtorArgs.length = 0; + onPrepareMock.mockResolvedValue(undefined); + onWorkerStartMock.mockResolvedValue(undefined); + onCompleteMock.mockResolvedValue(undefined); + serviceBeforeMock.mockResolvedValue(undefined); + remoteMock.mockResolvedValue(makeBrowser()); + deleteSessionMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('createElectrobunCapabilities', () => { + it('should build a capability with the electrobun browserName and service options', () => { + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + + expect(cap.browserName).toBe('electrobun'); + expect(cap['wdio:electrobunServiceOptions']).toMatchObject({ appBinaryPath: '/apps/Demo.app' }); + }); + + it('should throw when appBinaryPath is missing in native mode', () => { + expect(() => createElectrobunCapabilities({})).toThrow(/appBinaryPath is required/); + }); + + it('should not require appBinaryPath in browser mode', () => { + expect(() => + createElectrobunCapabilities({ mode: 'browser', devServerUrl: 'http://localhost:3000' }), + ).not.toThrow(); + }); + }); + + describe('init', () => { + it('should drive onPrepare + onWorkerStart, open the session, and run service.before', async () => { + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + + const browser = await init(cap); + + expect(onPrepareMock).toHaveBeenCalledTimes(1); + expect(onWorkerStartMock).toHaveBeenCalledTimes(1); + expect(remoteMock).toHaveBeenCalledTimes(1); + expect(serviceBeforeMock).toHaveBeenCalledTimes(1); + expect(browser).toBeDefined(); + }); + + it('should call launcher.onComplete when remote() fails', async () => { + remoteMock.mockRejectedValueOnce(new Error('chromedriver missing')); + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + + await expect(init(cap)).rejects.toThrow(/chromedriver missing/); + expect(onCompleteMock).toHaveBeenCalledTimes(1); + }); + + it('should tear down the session and call onComplete when service.before fails', async () => { + serviceBeforeMock.mockRejectedValueOnce(new Error('attach failed')); + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + + await expect(init(cap)).rejects.toThrow(/attach failed/); + expect(deleteSessionMock).toHaveBeenCalledTimes(1); + expect(onCompleteMock).toHaveBeenCalledTimes(1); + }); + }); + + describe('init option merging', () => { + it('should merge globalOptions into the worker service options (capability wins)', async () => { + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + + await init(cap, { cdpConnectionTimeout: 5000 }); + + expect(serviceCtorArgs[0]?.[0]).toMatchObject({ + cdpConnectionTimeout: 5000, + appBinaryPath: '/apps/Demo.app', + }); + }); + }); + + describe('cleanup', () => { + it('should run service teardown, delete the session, and call onComplete', async () => { + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + const browser = await init(cap); + + await cleanup(browser); + + expect(serviceAfterMock).toHaveBeenCalledTimes(1); + // after() is the whole teardown — afterSession() would only re-run the + // same closeBridges() against already-cleared state. + expect(serviceAfterSessionMock).not.toHaveBeenCalled(); + expect(deleteSessionMock).toHaveBeenCalledTimes(1); + expect(onCompleteMock).toHaveBeenCalledTimes(1); + }); + + it('should resolve when launcher.onComplete rejects (best-effort teardown)', async () => { + const cap = createElectrobunCapabilities({ appBinaryPath: '/apps/Demo.app' }); + const browser = await init(cap); + onCompleteMock.mockRejectedValueOnce(new Error('teardown boom')); + + await expect(cleanup(browser)).resolves.toBeUndefined(); + }); + + it('should warn and no-op when the browser was not created by init()', async () => { + const stray = makeBrowser(); + + await expect(cleanup(stray)).resolves.toBeUndefined(); + expect(onCompleteMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/electrobun-service/test/triggerDeeplink.spec.ts b/packages/electrobun-service/test/triggerDeeplink.spec.ts new file mode 100644 index 000000000..340ad2f59 --- /dev/null +++ b/packages/electrobun-service/test/triggerDeeplink.spec.ts @@ -0,0 +1,42 @@ +import * as nativeCore from '@wdio/native-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { triggerDeeplink } from '../src/commands/triggerDeeplink.js'; + +// Keep the pure validators (validateDeeplinkUrl / getPlatformCommand) real; only +// stub the side-effecting spawn so no real process is launched. +vi.mock('@wdio/native-core', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, executeDeeplinkCommand: vi.fn(async () => {}) }; +}); + +const originalPlatform = process.platform; + +function setPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); +} + +afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); + vi.clearAllMocks(); +}); + +describe('triggerDeeplink', () => { + it('should spawn the macOS open handler for a valid custom-scheme URL', async () => { + setPlatform('darwin'); + await triggerDeeplink('wdio-electrobun://open?path=/test'); + expect(nativeCore.executeDeeplinkCommand).toHaveBeenCalledWith('open', ['wdio-electrobun://open?path=/test']); + }); + + it('should reject http/https/file URLs', async () => { + setPlatform('darwin'); + await expect(triggerDeeplink('https://example.com')).rejects.toThrow(); + expect(nativeCore.executeDeeplinkCommand).not.toHaveBeenCalled(); + }); + + it('should throw the documented-gap error on non-macOS platforms', async () => { + setPlatform('win32'); + await expect(triggerDeeplink('wdio-electrobun://x')).rejects.toThrow(/macOS/); + expect(nativeCore.executeDeeplinkCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/electrobun-service/tsconfig.json b/packages/electrobun-service/tsconfig.json new file mode 100644 index 000000000..ab0e1bf7e --- /dev/null +++ b/packages/electrobun-service/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "dist", + "node_modules", + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/packages/electrobun-service/vitest.config.ts b/packages/electrobun-service/vitest.config.ts new file mode 100644 index 000000000..265543f9b --- /dev/null +++ b/packages/electrobun-service/vitest.config.ts @@ -0,0 +1,16 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/**/*.spec.ts'], + exclude: [...configDefaults.exclude, 'test/integration/**'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + include: ['src/**/*.ts'], + exclude: ['**/*.d.ts', '**/types.ts', 'src/index.ts'], + }, + }, +}); diff --git a/packages/electrobun-service/vitest.integration.config.ts b/packages/electrobun-service/vitest.integration.config.ts new file mode 100644 index 000000000..b8d4cec3d --- /dev/null +++ b/packages/electrobun-service/vitest.integration.config.ts @@ -0,0 +1,18 @@ +import { configDefaults, defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['test/integration/**/*.spec.ts'], + exclude: [...configDefaults.exclude], + // Don't fail when there are no integration tests yet — the integration + // suite lands once the launcher actually spawns the Electrobun binary. + passWithNoTests: true, + sequence: { concurrent: false }, + testTimeout: 30000, + hookTimeout: 15000, + teardownTimeout: 10000, + fileParallelism: false, + }, +}); diff --git a/packages/native-types/src/electrobun.ts b/packages/native-types/src/electrobun.ts new file mode 100644 index 000000000..2c80fff34 --- /dev/null +++ b/packages/native-types/src/electrobun.ts @@ -0,0 +1,175 @@ +import type { Mock } from '@wdio/native-spy'; +import type { + AbstractFn, + BaseServiceGlobalOptions, + BaseServiceOptions, + BrowserBase, + LogLevel, + MockOverride, + MockResult, + ServiceMockContext, +} from './shared.js'; + +// ============================================================================ +// Electrobun-Specific Types +// ============================================================================ + +/** + * Surface installed inside the Electrobun app's webview and injected as the + * first argument of `browser.electrobun.execute()` callbacks. Electrobun is a + * CDP-attach framework, so `execute` runs in a webview JS context via CDP + * `Runtime.callFunctionOn`; this is the in-page handle exposed on + * `window.__WDIO_ELECTROBUN__`. + * + * Kept intentionally open: the service currently injects an empty surface + * (`{}`); in-page handles are added here as they are exposed. + */ +export interface ElectrobunAPIs { + /** Optional log helpers, present when frontend log forwarding is wired. */ + log?: { + trace?: (msg: string) => void; + debug?: (msg: string) => void; + info?: (msg: string) => void; + warn?: (msg: string) => void; + error?: (msg: string) => void; + }; + [key: string]: unknown; +} + +interface ElectrobunMockContext extends ServiceMockContext { + results: MockResult[]; +} + +export interface ElectrobunMockInstance extends Omit { + mockImplementation(fn: AbstractFn): Promise; + mockImplementationOnce(fn: AbstractFn): Promise; + mockReturnValue(obj: unknown): Promise; + mockReturnValueOnce(obj: unknown): Promise; + mockResolvedValue(obj: unknown): Promise; + mockResolvedValueOnce(obj: unknown): Promise; + mockRejectedValue(obj: unknown): Promise; + mockRejectedValueOnce(obj: unknown): Promise; + mockClear(): Promise; + mockReset(): Promise; + mockRestore(): Promise; + mockReturnThis(): Promise; + mockName(name: string): ElectrobunMock; + getMockName(): string; + getMockImplementation(): AbstractFn; + update(): Promise; + __isElectrobunMock: boolean; + mock: ElectrobunMockContext; +} + +export interface ElectrobunMock + extends ElectrobunMockInstance { + new (...args: TArgs): TReturns; + (...args: TArgs): TReturns; +} + +/** + * Public `browser.electrobun.*` surface installed by `@wdio/electrobun-service`. + * + * Matches the shared cross-service surface (see the convergence standard in + * AGENTS.md / the add-native-service skill). `emitEvent` is omitted in 0.x — + * Electrobun's event bus lives in the Bun backend, which is not reachable over + * CDP (routable in principle via the in-webview RPC socket; deferred). + * `mockAll`/class-mock are Electron-only (no enumerable main-process API). + */ +export interface ElectrobunServiceAPI { + execute(script: string | ((eb: ElectrobunAPIs, ...a: A) => R), ...args: A): Promise; + + mock(target: string): Promise; + isMockFunction(targetOrFn: unknown): boolean; + clearAllMocks(prefix?: string): Promise; + resetAllMocks(prefix?: string): Promise; + restoreAllMocks(prefix?: string): Promise; + + /** Switch the active CDP target (window/webview) to the one labelled `label`. */ + switchWindow(label: string): Promise; + /** List all live window/webview labels in registration order. */ + listWindows(): Promise; + /** + * Trigger a deeplink (custom-protocol URL) at the OS level so the Electrobun + * app's registered `open-url` handler receives it. Use for testing the + * production protocol-handler code path; rejects http/https/file URLs. + * + * macOS-only in 0.x — Electrobun registers URL schemes via `Info.plist`; the + * Windows/Linux deeplink path is not yet supported upstream and rejects with + * a documented-gap error. + */ + triggerDeeplink(url: string): Promise; +} + +/** + * Electrobun-specific options merged from the shared base. Electrobun is a + * CDP-attach framework (like Electron, unlike the Wry-based Tauri/Dioxus), so + * there is no driver-provider config — the launcher spawns the app binary and + * the worker attaches over CDP. + */ +export interface ElectrobunServiceOptions extends BaseServiceOptions { + /** Test mode: `'native'` runs against a built Electrobun binary; `'browser'` runs the frontend against a dev server in plain Chrome. */ + mode?: 'native' | 'browser'; + /** When `mode === 'browser'`, the URL of the running dev server. */ + devServerUrl?: string; + /** Extra environment variables to set on the spawned app process (native mode). */ + env?: Record; + /** + * Force a specific CEF remote-debugging port. Normally unnecessary: the launcher + * allocates a free port per worker and **pins** it by writing it into that + * worker's bundle `build.json` (CEF reads the port from build.json, not from a + * launch arg). Parallel/multiremote runs each get a distinct auto-allocated, + * pinned port — set this only to force one instance onto a known fixed port. + */ + remoteDebuggingPort?: number; + /** Per-instance CDP connection timeout in ms. @default 10000 */ + cdpConnectionTimeout?: number; + /** Number of CDP connection retries before failing. @default 3 */ + cdpConnectionRetryCount?: number; + /** Interval between CDP connection retries in ms. @default 100 */ + cdpConnectionRetryInterval?: number; + /** Default window/target label for execute/mock calls. */ + windowLabel?: string; +} + +export interface ElectrobunServiceGlobalOptions extends BaseServiceGlobalOptions { + mode?: 'native' | 'browser'; + devServerUrl?: string; + env?: Record; + remoteDebuggingPort?: number; + cdpConnectionTimeout?: number; + cdpConnectionRetryCount?: number; + cdpConnectionRetryInterval?: number; + windowLabel?: string; + backendLogLevel?: LogLevel; + frontendLogLevel?: LogLevel; +} + +/** + * Capability shape for Electrobun apps. As a CDP-attach service the launcher + * drives the app through Chromedriver attached to the CEF debugger port, so the + * effective capability is `browserName: 'chrome'` + `goog:chromeOptions`; there + * is no `electrobun:options` capability (CDP frameworks have no in-app plumbing). + * + * Declared as a plain interface — do NOT extend + * `Capabilities.RequestedStandaloneCapabilities` (Rollup's TS plugin can't + * extend dynamic-member interfaces). Intersect at the service level instead. + */ +export interface ElectrobunCapabilities { + browserName?: 'chrome' | 'electrobun' | string; + 'goog:chromeOptions'?: Record; + 'wdio:electrobunServiceOptions'?: ElectrobunServiceOptions; +} + +export interface ElectrobunBrowserExtension extends BrowserBase { + electrobun: ElectrobunServiceAPI; +} + +declare global { + interface Window { + __WDIO_ELECTROBUN__?: { + log?: ElectrobunAPIs['log']; + [key: string]: unknown; + }; + } +} diff --git a/packages/native-types/src/index.ts b/packages/native-types/src/index.ts index ef71d787c..fcbbb381f 100644 --- a/packages/native-types/src/index.ts +++ b/packages/native-types/src/index.ts @@ -14,6 +14,17 @@ export type { DioxusServiceGlobalOptions, DioxusServiceOptions, } from './dioxus.js'; +// Electrobun types +export type { + ElectrobunAPIs, + ElectrobunBrowserExtension, + ElectrobunCapabilities, + ElectrobunMock, + ElectrobunMockInstance, + ElectrobunServiceAPI, + ElectrobunServiceGlobalOptions, + ElectrobunServiceOptions, +} from './electrobun.js'; // Electron types export type { ApiCommand, @@ -103,13 +114,18 @@ export type { // ============================================================================ import type { DioxusBrowserExtension } from './dioxus.js'; +import type { ElectrobunBrowserExtension } from './electrobun.js'; import type { ElectronBrowserExtension } from './electron.js'; import type { TauriBrowserExtension } from './tauri.js'; /** - * Browser extension that supports Electron, Tauri, and Dioxus services + * Browser extension that supports Electron, Tauri, Dioxus, and Electrobun services */ -export interface BrowserExtension extends ElectronBrowserExtension, TauriBrowserExtension, DioxusBrowserExtension {} +export interface BrowserExtension + extends ElectronBrowserExtension, + TauriBrowserExtension, + DioxusBrowserExtension, + ElectrobunBrowserExtension {} // ============================================================================ // Module Augmentation (WebdriverIO) @@ -117,6 +133,7 @@ export interface BrowserExtension extends ElectronBrowserExtension, TauriBrowser import type { fn as vitestFn } from '@wdio/native-spy'; import type { DioxusServiceGlobalOptions, DioxusServiceOptions } from './dioxus.js'; +import type { ElectrobunServiceGlobalOptions, ElectrobunServiceOptions } from './electrobun.js'; import type { ElectronInterface, ElectronServiceGlobalOptions, @@ -135,18 +152,28 @@ declare global { // biome-ignore lint/style/noNamespace: This is a legitimate use of namespace for global augmentation namespace WebdriverIO { - interface Browser extends ElectronBrowserExtension, TauriBrowserExtension, DioxusBrowserExtension {} + interface Browser + extends ElectronBrowserExtension, + TauriBrowserExtension, + DioxusBrowserExtension, + ElectrobunBrowserExtension {} interface Element extends ElementBase {} - interface MultiRemoteBrowser extends ElectronBrowserExtension, TauriBrowserExtension, DioxusBrowserExtension {} + interface MultiRemoteBrowser + extends ElectronBrowserExtension, + TauriBrowserExtension, + DioxusBrowserExtension, + ElectrobunBrowserExtension {} interface Capabilities { 'wdio:electronServiceOptions'?: ElectronServiceOptions; 'wdio:tauriServiceOptions'?: TauriServiceOptions; 'wdio:dioxusServiceOptions'?: DioxusServiceOptions; + 'wdio:electrobunServiceOptions'?: ElectrobunServiceOptions; } interface ServiceOption extends ElectronServiceGlobalOptions, TauriServiceGlobalOptions, - DioxusServiceGlobalOptions {} + DioxusServiceGlobalOptions, + ElectrobunServiceGlobalOptions {} } var __name: (func: Fn) => Fn; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3aeed278..8a9a1ab2f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: '@wdio/dioxus-service': specifier: link:../packages/dioxus-service version: link:../packages/dioxus-service + '@wdio/electrobun-service': + specifier: link:../packages/electrobun-service + version: link:../packages/electrobun-service '@wdio/electron-service': specifier: link:../packages/electron-service version: link:../packages/electron-service @@ -189,6 +192,19 @@ importers: fixtures/e2e-apps/dioxus: {} + fixtures/e2e-apps/electrobun: + dependencies: + electrobun: + specifier: 1.18.1 + version: 1.18.1 + devDependencies: + '@types/bun': + specifier: 1.3.14 + version: 1.3.14 + typescript: + specifier: 5.9.3 + version: 5.9.3 + fixtures/e2e-apps/electron-builder: devDependencies: '@types/node': @@ -355,6 +371,43 @@ importers: specifier: ^5.0.0 version: 5.9.3 + fixtures/package-tests/electrobun-app: + dependencies: + electrobun: + specifier: 1.18.1 + version: 1.18.1 + devDependencies: + '@types/bun': + specifier: 1.3.14 + version: 1.3.14 + '@types/mocha': + specifier: 10.0.10 + version: 10.0.10 + '@wdio/cli': + specifier: 9.27.1 + version: 9.27.1(@types/node@25.9.1)(expect-webdriverio@5.6.5)(puppeteer-core@25.0.4) + '@wdio/electrobun-service': + specifier: workspace:* + version: link:../../../packages/electrobun-service + '@wdio/globals': + specifier: 9.27.1 + version: 9.27.1(expect-webdriverio@5.6.5)(webdriverio@9.27.1(puppeteer-core@25.0.4)) + '@wdio/local-runner': + specifier: 9.27.1 + version: 9.27.1(@wdio/globals@9.27.1)(webdriverio@9.27.1(puppeteer-core@25.0.4)) + '@wdio/mocha-framework': + specifier: 9.27.1 + version: 9.27.1 + '@wdio/native-types': + specifier: workspace:* + version: link:../../../packages/native-types + '@wdio/spec-reporter': + specifier: 9.27.1 + version: 9.27.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + fixtures/package-tests/electron-builder-app-cjs: devDependencies: '@types/node': @@ -799,6 +852,116 @@ importers: specifier: ^4.1.7 version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.3.2(@types/node@25.9.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/electrobun-cdp-bridge: + dependencies: + '@wdio/native-utils': + specifier: workspace:* + version: link:../native-utils + wait-port: + specifier: ^1.1.0 + version: 1.1.0 + ws: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@vitest/coverage-v8': + specifier: ^4.1.7 + version: 4.1.7(vitest@4.1.7) + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + devtools-protocol: + specifier: ^0.0.1634055 + version: 0.0.1634055 + get-port: + specifier: ^7.1.0 + version: 7.2.0 + nock: + specifier: ^14.0.15 + version: 14.0.15 + shx: + specifier: ^0.4.0 + version: 0.4.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.3.2(@types/node@25.9.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + + packages/electrobun-service: + dependencies: + '@wdio/electrobun-cdp-bridge': + specifier: workspace:* + version: link:../electrobun-cdp-bridge + '@wdio/globals': + specifier: catalog:default + version: 9.27.1(expect-webdriverio@5.6.5)(webdriverio@9.27.1(puppeteer-core@25.0.4)) + '@wdio/logger': + specifier: catalog:default + version: 9.18.0 + '@wdio/native-core': + specifier: workspace:* + version: link:../native-core + '@wdio/native-spy': + specifier: workspace:* + version: link:../native-spy + '@wdio/native-types': + specifier: workspace:* + version: link:../native-types + '@wdio/native-utils': + specifier: workspace:* + version: link:../native-utils + '@wdio/spec-reporter': + specifier: catalog:default + version: 9.27.1 + '@wdio/types': + specifier: catalog:default + version: 9.27.1 + debug: + specifier: ^4.4.3 + version: 4.4.3(supports-color@8.1.1) + get-port: + specifier: ^7.1.0 + version: 7.2.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + webdriverio: + specifier: catalog:default + version: 9.27.1(puppeteer-core@25.0.4) + devDependencies: + '@types/debug': + specifier: ^4.1.12 + version: 4.1.13 + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + '@vitest/coverage-v8': + specifier: ^4.1.7 + version: 4.1.7(vitest@4.1.7) + shx: + specifier: ^0.4.0 + version: 0.4.0 + tsx: + specifier: ^4.22.3 + version: 4.22.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(happy-dom@20.9.0)(jsdom@29.1.1)(vite@7.3.2(@types/node@25.9.1)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.3)(yaml@2.9.0)) + packages/electron-cdp-bridge: dependencies: '@wdio/native-utils': @@ -1314,6 +1477,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babylonjs/core@7.54.3': + resolution: {integrity: sha512-P5ncXVd8GEUJLhwloP9V0oVwQYIrvZztguVeLlvd5Rx+9aQnenKjpV8auJ6SRsUlAmNZU4pFTKzwF6o2EUfhAw==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -3292,6 +3458,9 @@ packages: cpu: [arm64] os: [win32] + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -3352,6 +3521,9 @@ packages: '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + '@types/node@17.0.45': + resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} @@ -3982,6 +4154,9 @@ packages: builder-util@26.8.1: resolution: {integrity: sha512-pm1lTYbGyc90DHgCDO7eo8Rl4EqKLciayNbZqGziqnH9jrlKe8ZANGdityLZU+pJh16dfzjAx2xQq9McuIPEtw==} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -4251,6 +4426,10 @@ packages: engines: {node: '>=20'} hasBin: true + cross-spawn-windows-exe@1.2.0: + resolution: {integrity: sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw==} + engines: {node: '>= 10'} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -4449,6 +4628,10 @@ packages: engines: {node: '>=0.12.18'} hasBin: true + electrobun@1.18.1: + resolution: {integrity: sha512-tgZ+WKGskn/1/Y5i1mpVCCkgRa1O31Tz7MIArBTK44GfPzT43uOq9c/HvxdQGrLeZV8ZvjK1lQrwqSXCgh6tvA==} + hasBin: true + electron-builder-squirrel-windows@26.8.1: resolution: {integrity: sha512-o288fIdgPLHA76eDrFADHPoo7VyGkDCYbLV1GzndaMSAVBoZrGvM9m2IehdcVMzdAZJ2eV9bgyissQXHv5tGzA==} @@ -5214,10 +5397,6 @@ packages: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} - ip-address@10.1.0: - resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} - engines: {node: '>= 12'} - ip-address@10.2.0: resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} engines: {node: '>= 12'} @@ -5233,6 +5412,11 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -5309,6 +5493,10 @@ packages: resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} engines: {node: '>=18'} + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -6155,6 +6343,11 @@ packages: resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} engines: {node: '>=10.4.0'} + png-to-ico@2.1.8: + resolution: {integrity: sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w==} + engines: {node: '>=8'} + hasBin: true + pngjs@6.0.0: resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==} engines: {node: '>=12.13.0'} @@ -6258,6 +6451,11 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + rcedit@4.0.1: + resolution: {integrity: sha512-bZdaQi34krFWhrDn+O53ccBDw0MkAT2Vhu75SqhtvhQu4OPyFM4RoVheyYiVQYdjhUi6EJMVWQ0tR6bCIYVkUg==} + engines: {node: '>= 14.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -6570,10 +6768,6 @@ packages: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} - socks@2.8.7: - resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - socks@2.8.9: resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} @@ -6814,6 +7008,9 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + three@0.165.0: + resolution: {integrity: sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==} + time-span@5.1.0: resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} engines: {node: '>=12'} @@ -7513,6 +7710,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babylonjs/core@7.54.3': {} + '@bcoe/v8-coverage@1.0.2': {} '@biomejs/biome@2.4.15': @@ -8978,7 +9177,6 @@ snapshots: '@malept/cross-spawn-promise@1.1.1': dependencies: cross-spawn: 7.0.6 - optional: true '@malept/cross-spawn-promise@2.0.0': dependencies: @@ -9519,6 +9717,10 @@ snapshots: '@turbo/windows-arm64@2.9.14': optional: true + '@types/bun@1.3.14': + dependencies: + bun-types: 1.3.14 + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -9579,6 +9781,8 @@ snapshots: '@types/node@16.9.1': {} + '@types/node@17.0.45': {} + '@types/node@20.19.41': dependencies: undici-types: 6.21.0 @@ -10420,6 +10624,10 @@ snapshots: transitivePeerDependencies: - supports-color + bun-types@1.3.14: + dependencies: + '@types/node': 25.9.1 + cac@6.7.14: {} cacache@16.1.3: @@ -10721,6 +10929,12 @@ snapshots: '@epic-web/invariant': 1.0.0 cross-spawn: 7.0.6 + cross-spawn-windows-exe@1.2.0: + dependencies: + '@malept/cross-spawn-promise': 1.1.1 + is-wsl: 2.2.0 + which: 2.0.2 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -10934,6 +11148,17 @@ snapshots: dependencies: jake: 10.9.4 + electrobun@1.18.1: + dependencies: + '@babylonjs/core': 7.54.3 + '@types/bun': 1.3.14 + png-to-ico: 2.1.8 + proxy-agent: 6.5.0 + rcedit: 4.0.1 + three: 0.165.0 + transitivePeerDependencies: + - supports-color + electron-builder-squirrel-windows@26.8.1(dmg-builder@26.8.1): dependencies: app-builder-lib: 26.8.1(dmg-builder@26.8.1)(electron-builder-squirrel-windows@26.8.1) @@ -11932,8 +12157,6 @@ snapshots: interpret@3.1.1: {} - ip-address@10.1.0: {} - ip-address@10.2.0: {} is-arrayish@0.2.1: {} @@ -11946,6 +12169,8 @@ snapshots: dependencies: hasown: 2.0.2 + is-docker@2.2.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -11990,6 +12215,10 @@ snapshots: is-unicode-supported@2.1.0: {} + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + isarray@1.0.0: {} isbinaryfile@4.0.10: {} @@ -12900,6 +13129,12 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + png-to-ico@2.1.8: + dependencies: + '@types/node': 17.0.45 + minimist: 1.2.8 + pngjs: 6.0.0 + pngjs@6.0.0: {} pngjs@7.0.0: {} @@ -13003,6 +13238,10 @@ snapshots: dependencies: safe-buffer: 5.2.1 + rcedit@4.0.1: + dependencies: + cross-spawn-windows-exe: 1.2.0 + react-is@18.3.1: {} read-binary-file-arch@1.0.6: @@ -13370,15 +13609,10 @@ snapshots: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@8.1.1) - socks: 2.8.7 + socks: 2.8.9 transitivePeerDependencies: - supports-color - socks@2.8.7: - dependencies: - ip-address: 10.1.0 - smart-buffer: 4.2.0 - socks@2.8.9: dependencies: ip-address: 10.2.0 @@ -13615,6 +13849,8 @@ snapshots: transitivePeerDependencies: - react-native-b4a + three@0.165.0: {} + time-span@5.1.0: dependencies: convert-hrtime: 5.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c7cc533ce..0108681d2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,6 +7,7 @@ packages: - fixtures/e2e-apps/electron-script - fixtures/e2e-apps/tauri - fixtures/e2e-apps/dioxus + - fixtures/e2e-apps/electrobun - fixtures/package-tests/electron-builder-app-cjs - fixtures/package-tests/electron-builder-app-esm - fixtures/package-tests/electron-forge-app-cjs @@ -15,6 +16,7 @@ packages: - fixtures/package-tests/electron-script-app-esm - fixtures/package-tests/tauri-app - fixtures/package-tests/dioxus-app + - fixtures/package-tests/electrobun-app allowBuilds: edgedriver: false electron: true diff --git a/releasekit.config.json b/releasekit.config.json index 1f783d35d..17cde7af0 100644 --- a/releasekit.config.json +++ b/releasekit.config.json @@ -29,7 +29,11 @@ "electron-script-app-example-esm", "electron-script-e2e-app", "tauri-app-example", - "tauri-e2e-app" + "tauri-e2e-app", + "dioxus-app-example", + "wdio-dioxus-e2e-app", + "electrobun-app-example", + "electrobun-e2e-app" ] }, "ci": { @@ -40,7 +44,9 @@ "scope:types": "@wdio/native-types", "scope:spy": "@wdio/native-spy", "scope:tauri": "@wdio/tauri-*", - "scope:electron": "@wdio/electron-*" + "scope:dioxus": "@wdio/dioxus-*", + "scope:electron": "@wdio/electron-*", + "scope:electrobun": "@wdio/electrobun-*" } }, "notes": { diff --git a/scripts/test-package.ts b/scripts/test-package.ts index 455d3f811..50bea1608 100755 --- a/scripts/test-package.ts +++ b/scripts/test-package.ts @@ -1,7 +1,7 @@ #!/usr/bin/env tsx /** * Script to test the wdio-electron-service, wdio-tauri-service, and wdio-dioxus-service packages in the package test apps - * Usage: pnpx tsx scripts/test-package.ts [--package=] [--service=] [--module-type=] [--mode=] [--skip-build] + * Usage: pnpx tsx scripts/test-package.ts [--package=] [--service=] [--module-type=] [--mode=] [--skip-build] * * Examples: * pnpx tsx scripts/test-package.ts @@ -11,6 +11,7 @@ * pnpx tsx scripts/test-package.ts --service=electron --mode=browser * pnpx tsx scripts/test-package.ts --service=tauri * pnpx tsx scripts/test-package.ts --service=dioxus + * pnpx tsx scripts/test-package.ts --service=electrobun (macOS only) * pnpx tsx scripts/test-package.ts --package=electron-builder-app-cjs * pnpx tsx scripts/test-package.ts --package=electron-builder-app-esm * pnpx tsx scripts/test-package.ts --package=tauri-app --skip-build @@ -78,7 +79,7 @@ function readWorkspaceOverrides(): Record { interface TestOptions { package?: string; skipBuild?: boolean; - service?: 'electron' | 'tauri' | 'dioxus' | 'both'; + service?: 'electron' | 'tauri' | 'dioxus' | 'electrobun' | 'all'; moduleType?: 'cjs' | 'esm' | 'both'; /** 'native' runs the existing app-launch tests. 'browser' starts a static * HTTP server against the fixture's `browser/` directory and runs the @@ -134,14 +135,18 @@ function execCommand(command: string, cwd: string, description: string) { } } -async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'both' = 'both'): Promise<{ +async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'electrobun' | 'all' = 'all'): Promise<{ electronServicePath?: string; tauriServicePath?: string; dioxusServicePath?: string; + electrobunServicePath?: string; utilsPath: string; spyPath: string; corePath: string; typesPath?: string; + // Holds the electron-cdp-bridge tarball for `electron`, or the electrobun-cdp-bridge + // tarball for `electrobun` — the two services never pack together (electrobun is not + // part of `all`), so one field is unambiguous per run. cdpBridgePath?: string; }> { log(`Building and packing services and dependencies (service: ${service})...`); @@ -150,11 +155,14 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b // Build only the packages required for the requested service. Each // service depends on @wdio/native-core (extracted in the Dioxus // foundation work), so include it in every filter set. - const buildFilters: Record<'electron' | 'tauri' | 'dioxus' | 'both', string> = { + const buildFilters: Record<'electron' | 'tauri' | 'dioxus' | 'electrobun' | 'all', string> = { electron: '--filter=@wdio/electron-service --filter=@wdio/native-spy --filter=@wdio/native-core', tauri: '--filter=@wdio/tauri-service --filter=@wdio/native-core', dioxus: '--filter=@wdio/dioxus-service --filter=@wdio/native-core', - both: '--filter=./packages/*', + electrobun: '--filter=@wdio/electrobun-service --filter=@wdio/native-spy --filter=@wdio/native-core', + // `all` builds every package; it does NOT include electrobun's fixtures — electrobun + // is macOS-only and always run via its own `--service=electrobun` job. + all: '--filter=./packages/*', }; execCommand(`pnpm turbo run build ${buildFilters[service]}`, rootDir, `Building packages for ${service}`); @@ -197,6 +205,7 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b electronServicePath?: string; tauriServicePath?: string; dioxusServicePath?: string; + electrobunServicePath?: string; utilsPath: string; spyPath: string; corePath: string; @@ -205,7 +214,7 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b } = { utilsPath, spyPath, corePath }; // Pack Electron service and dependencies if needed - if (service === 'electron' || service === 'both') { + if (service === 'electron' || service === 'all') { const electronServiceDir = normalize(join(rootDir, 'packages', 'electron-service')); const typesDir = normalize(join(rootDir, 'packages', 'native-types')); const cdpBridgeDir = normalize(join(rootDir, 'packages', 'electron-cdp-bridge')); @@ -227,7 +236,7 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b } // Pack Tauri service if needed - if (service === 'tauri' || service === 'both') { + if (service === 'tauri' || service === 'all') { const tauriServiceDir = normalize(join(rootDir, 'packages', 'tauri-service')); const typesDir = normalize(join(rootDir, 'packages', 'native-types')); if (!existsSync(tauriServiceDir)) { @@ -243,7 +252,7 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b } // Pack Dioxus service if needed - if (service === 'dioxus' || service === 'both') { + if (service === 'dioxus' || service === 'all') { const dioxusServiceDir = normalize(join(rootDir, 'packages', 'dioxus-service')); if (!existsSync(dioxusServiceDir)) { throw new Error(`Dioxus service directory does not exist: ${dioxusServiceDir}`); @@ -260,6 +269,26 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b result.dioxusServicePath = findTgzFile(dioxusServiceDir, 'wdio-dioxus-service-'); } + // Pack Electrobun service if needed (CDP archetype like Electron: service + + // native-types + its own electrobun-cdp-bridge). Never part of `all`. + if (service === 'electrobun') { + const electrobunServiceDir = normalize(join(rootDir, 'packages', 'electrobun-service')); + const typesDir = normalize(join(rootDir, 'packages', 'native-types')); + const cdpBridgeDir = normalize(join(rootDir, 'packages', 'electrobun-cdp-bridge')); + if (!existsSync(electrobunServiceDir)) { + throw new Error(`Electrobun service directory does not exist: ${electrobunServiceDir}`); + } + if (!existsSync(cdpBridgeDir)) { + throw new Error(`Electrobun CDP Bridge directory does not exist: ${cdpBridgeDir}`); + } + execCommand('pnpm pack', typesDir, 'Packing @wdio/native-types'); + execCommand('pnpm pack', cdpBridgeDir, 'Packing @wdio/electrobun-cdp-bridge'); + execCommand('pnpm pack', electrobunServiceDir, 'Packing @wdio/electrobun-service'); + result.typesPath = findTgzFile(typesDir, 'wdio-native-types-'); + result.cdpBridgePath = findTgzFile(cdpBridgeDir, 'wdio-electrobun-cdp-bridge-'); + result.electrobunServicePath = findTgzFile(electrobunServiceDir, 'wdio-electrobun-service-'); + } + log(`📦 Packages packed:`); log(` Utils: ${utilsPath}`); log(` Spy: ${spyPath}`); @@ -281,6 +310,11 @@ async function buildAndPackService(service: 'electron' | 'tauri' | 'dioxus' | 'b log(` Types: ${result.typesPath}`); } } + if (result.electrobunServicePath) { + log(` Electrobun Service: ${result.electrobunServicePath}`); + log(` Types: ${result.typesPath}`); + log(` CDP Bridge: ${result.cdpBridgePath}`); + } return result; } catch (error) { @@ -351,13 +385,14 @@ async function testExample( electronServicePath?: string; tauriServicePath?: string; dioxusServicePath?: string; + electrobunServicePath?: string; utilsPath: string; spyPath: string; corePath: string; typesPath?: string; cdpBridgePath?: string; }, - service: 'electron' | 'tauri' | 'dioxus', + service: 'electron' | 'tauri' | 'dioxus' | 'electrobun', skipBuild: boolean, mode: 'native' | 'browser' = 'native', ) { @@ -457,6 +492,14 @@ async function testExample( overrides['@wdio/dioxus-service'] = `file:${packages.dioxusServicePath}`; overrides['@wdio/native-types'] = `file:${packages.typesPath}`; packagesToInstall.push(packages.typesPath, packages.dioxusServicePath); + } else if (service === 'electrobun') { + if (!packages.electrobunServicePath || !packages.typesPath || !packages.cdpBridgePath) { + throw new Error('Electrobun service packages not available'); + } + overrides['@wdio/electrobun-service'] = `file:${packages.electrobunServicePath}`; + overrides['@wdio/native-types'] = `file:${packages.typesPath}`; + overrides['@wdio/electrobun-cdp-bridge'] = `file:${packages.cdpBridgePath}`; + packagesToInstall.push(packages.typesPath, packages.cdpBridgePath, packages.electrobunServicePath); } packageJson.pnpm = { @@ -726,6 +769,26 @@ async function testExample( } else if (packageJson.scripts?.build) { execCommand('pnpm build', packageDir, `Building ${packageName} app`); } + } else if (service === 'electrobun' && mode === 'native') { + // CDP-attach CEF bundle. CI builds it as a separate macOS artifact (the ~150MB + // CEF download is slow), so in skipBuild mode copy the pre-built bundle rather + // than re-running `electrobun build` in the isolated environment. + const sourceBuildDir = join(rootDir, 'fixtures', 'package-tests', 'electrobun-app', 'build'); + const destBuildDir = join(packageDir, 'build'); + if (skipBuild) { + if (!existsSync(sourceBuildDir)) { + throw new Error( + `Pre-built Electrobun bundle not found at ${sourceBuildDir}. ` + + 'Was the build artifact downloaded and extracted correctly?', + ); + } + log(`Copying pre-built Electrobun bundle from ${sourceBuildDir}...`); + mkdirSync(destBuildDir, { recursive: true }); + cpSync(sourceBuildDir, destBuildDir, { recursive: true }); + log(`✅ Pre-built Electrobun bundle copied successfully`); + } else if (packageJson.scripts?.build) { + execCommand('pnpm build', packageDir, `Building ${packageName} app`); + } } else if (packageJson.scripts?.build && mode === 'native') { // Build other apps (e.g. Electron) in isolated environment execCommand('pnpm build', packageDir, `Building ${packageName} app`); @@ -849,13 +912,21 @@ async function main() { const args = process.argv.slice(2); const serviceArg = args.find((arg) => arg.startsWith('--service='))?.split('=')[1]; - // Validate service argument if provided, default to 'both' if not provided - let service: 'electron' | 'tauri' | 'dioxus' | 'both' = 'both'; + // Validate service argument if provided, default to 'all' if not provided + let service: 'electron' | 'tauri' | 'dioxus' | 'electrobun' | 'all' = 'all'; if (serviceArg) { - if (serviceArg === 'electron' || serviceArg === 'tauri' || serviceArg === 'dioxus' || serviceArg === 'both') { + if ( + serviceArg === 'electron' || + serviceArg === 'tauri' || + serviceArg === 'dioxus' || + serviceArg === 'electrobun' || + serviceArg === 'all' + ) { service = serviceArg; } else { - throw new Error(`Invalid service value: ${serviceArg}. Must be 'electron', 'tauri', 'dioxus', or 'both'`); + throw new Error( + `Invalid service value: ${serviceArg}. Must be 'electron', 'tauri', 'dioxus', 'electrobun', or 'all'`, + ); } } @@ -892,6 +963,7 @@ async function main() { electronServicePath?: string; tauriServicePath?: string; dioxusServicePath?: string; + electrobunServicePath?: string; utilsPath: string; spyPath: string; corePath: string; @@ -918,7 +990,7 @@ async function main() { corePath: findTgzFile(coreDir, 'wdio-native-core-'), }; - if (options.service === 'electron' || options.service === 'both') { + if (options.service === 'electron' || options.service === 'all') { const electronServiceDir = normalize(join(rootDir, 'packages', 'electron-service')); const typesDir = normalize(join(rootDir, 'packages', 'native-types')); const cdpBridgeDir = normalize(join(rootDir, 'packages', 'electron-cdp-bridge')); @@ -927,20 +999,29 @@ async function main() { packages.cdpBridgePath = findTgzFile(cdpBridgeDir, 'wdio-electron-cdp-bridge-'); } - if (options.service === 'tauri' || options.service === 'both') { + if (options.service === 'tauri' || options.service === 'all') { const tauriServiceDir = normalize(join(rootDir, 'packages', 'tauri-service')); const typesDir = normalize(join(rootDir, 'packages', 'native-types')); packages.tauriServicePath = findTgzFile(tauriServiceDir, 'wdio-tauri-service-'); packages.typesPath = findTgzFile(typesDir, 'wdio-native-types-'); } - if (options.service === 'dioxus' || options.service === 'both') { + if (options.service === 'dioxus' || options.service === 'all') { const dioxusServiceDir = normalize(join(rootDir, 'packages', 'dioxus-service')); const typesDir = normalize(join(rootDir, 'packages', 'native-types')); packages.dioxusServicePath = findTgzFile(dioxusServiceDir, 'wdio-dioxus-service-'); packages.typesPath = findTgzFile(typesDir, 'wdio-native-types-'); } + if (options.service === 'electrobun') { + const electrobunServiceDir = normalize(join(rootDir, 'packages', 'electrobun-service')); + const typesDir = normalize(join(rootDir, 'packages', 'native-types')); + const cdpBridgeDir = normalize(join(rootDir, 'packages', 'electrobun-cdp-bridge')); + packages.electrobunServicePath = findTgzFile(electrobunServiceDir, 'wdio-electrobun-service-'); + packages.typesPath = findTgzFile(typesDir, 'wdio-native-types-'); + packages.cdpBridgePath = findTgzFile(cdpBridgeDir, 'wdio-electrobun-cdp-bridge-'); + } + log(`📦 Using existing packages:`); log(` Utils: ${packages.utilsPath}`); log(` Spy: ${packages.spyPath}`); @@ -956,6 +1037,10 @@ async function main() { if (packages.dioxusServicePath) { log(` Dioxus Service: ${packages.dioxusServicePath}`); } + if (packages.electrobunServicePath) { + log(` Electrobun Service: ${packages.electrobunServicePath}`); + log(` CDP Bridge: ${packages.cdpBridgePath}`); + } } else { packages = await buildAndPackService(options.service); } @@ -979,11 +1064,19 @@ async function main() { filteredDirs = packageTestDirs.filter((name) => name.startsWith('tauri-')); } else if (options.service === 'dioxus') { filteredDirs = packageTestDirs.filter((name) => name.startsWith('dioxus-')); + } else if (options.service === 'electrobun') { + filteredDirs = packageTestDirs.filter((name) => name.startsWith('electrobun-')); + } else { + // 'all' = electron + tauri + dioxus (the services sharing the cross-OS matrix). + // Electrobun is NEVER part of 'all' — it's macOS-only with a bespoke CEF flow and + // buildAndPackService('all') doesn't pack its tarball, so its fixtures must be + // excluded or a bare `pnpm test:package` would try to test electrobun-app without a + // packed service and throw. Run it explicitly via `--service=electrobun`. + filteredDirs = packageTestDirs.filter((name) => !name.startsWith('electrobun-')); } - // If service is 'both', don't filter // Filter by module type for Electron packages - if (options.service === 'electron' || options.service === 'both') { + if (options.service === 'electron' || options.service === 'all') { if (options.moduleType === 'cjs') { filteredDirs = filteredDirs.filter((name) => name.endsWith('-cjs') || !name.match(/-cjs$|-esm$/)); } else if (options.moduleType === 'esm') { @@ -1006,7 +1099,7 @@ async function main() { if ( options.moduleType === 'both' && !options.package && - (options.service === 'electron' || options.service === 'both') + (options.service === 'electron' || options.service === 'all') ) { const expandedPackages: string[] = []; const seenBaseNames = new Set(); @@ -1052,12 +1145,15 @@ async function main() { continue; } - // Detect service type from package name (already filtered by prefix, but needed for testExample) - const detectedService: 'electron' | 'tauri' | 'dioxus' = packageName.startsWith('tauri-') - ? 'tauri' - : packageName.startsWith('dioxus-') - ? 'dioxus' - : 'electron'; + // Detect service type from the package-name prefix (already filtered, but + // testExample needs it). Electron is the default — its fixtures carry no prefix. + const servicePrefixes = [ + ['tauri-', 'tauri'], + ['dioxus-', 'dioxus'], + ['electrobun-', 'electrobun'], + ] as const; + const detectedService: 'electron' | 'tauri' | 'dioxus' | 'electrobun' = + servicePrefixes.find(([prefix]) => packageName.startsWith(prefix))?.[1] ?? 'electron'; // Log module type for Electron packages if (detectedService === 'electron') { diff --git a/turbo.json b/turbo.json index 09ca8be1e..1d2a9242e 100644 --- a/turbo.json +++ b/turbo.json @@ -257,6 +257,23 @@ "dist/**" ] }, + "@wdio/electrobun-cdp-bridge#build": { + "dependsOn": [ + "@wdio/native-utils#build", + "typecheck" + ], + "outputs": [ + "dist/**" + ] + }, + "@wdio/electrobun-service#build": { + "dependsOn": [ + "@wdio/electrobun-cdp-bridge#build" + ], + "outputs": [ + "dist/**" + ] + }, "@wdio/tauri-plugin#build": { "dependsOn": [ "@wdio/tauri-plugin#build:js"