diff --git a/.github/workflows/e2e-agent-review.yml b/.github/workflows/e2e-agent-review.yml index 1d0af3552..2ef8cdfef 100644 --- a/.github/workflows/e2e-agent-review.yml +++ b/.github/workflows/e2e-agent-review.yml @@ -1,12 +1,13 @@ --- name: E2E (Linux) - agent-review -# CEF LIMITATION: Linux E2E via tauri-driver requires WebKitWebDriver -# (webkit2gtk), but this app uses the CEF runtime (tauri-runtime-cef). -# WebKitWebDriver cannot drive a CEF-backed WebView, so Linux CEF E2E is not -# a supported CI gate. Keep this workflow manual/diagnostic until the CEF fork -# adds a ChromeDriver-based automation path or an alternative harness is wired. -# Use macOS/Appium for supported local or manual desktop E2E runs. -# See also: test.yml where the e2e-linux job is commented out for the same reason. +# +# Runs the agent-review spec on Linux via the unified Appium Chromium +# driver attached to CEF's CDP port. Same driver path as `e2e.yml`; this +# workflow exists because agent-review needs a longer timeout and its own +# artifact retention. +# +# Previously: tauri-driver + WebKitWebDriver, which can't drive a +# CEF-backed WebView. That path is dead. on: workflow_dispatch: permissions: @@ -16,7 +17,7 @@ concurrency: cancel-in-progress: true jobs: e2e-agent-review: - name: E2E agent-review (Linux / tauri-driver) + name: E2E agent-review (Linux / Appium Chromium) runs-on: ubuntu-22.04 timeout-minutes: 60 steps: @@ -47,7 +48,7 @@ jobs: - name: Install Rust (rust-toolchain.toml) if: steps.gate.outputs.present == 'true' uses: dtolnay/rust-toolchain@1.93.0 - - name: Install system dependencies + - name: Install system dependencies (CEF + Xvfb) if: steps.gate.outputs.present == 'true' run: | sudo apt-get update @@ -55,8 +56,8 @@ jobs: libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev \ librsvg2-dev patchelf \ xvfb at-spi2-core dbus-x11 \ - webkit2gtk-driver \ - libasound2-dev libxdo-dev libxtst-dev libx11-dev libevdev-dev + libasound2-dev libxdo-dev libxtst-dev libx11-dev libevdev-dev \ + unzip curl python3 - name: Cargo.lock fingerprint (deps only) if: steps.gate.outputs.present == 'true' id: cargo-lock-fingerprint @@ -76,9 +77,11 @@ jobs: restore-keys: | ${{ runner.os }}-e2e-agentreview-cargo- ${{ runner.os }}-e2e-cargo- - - name: Install tauri-driver + - name: Install Appium and chromium driver if: steps.gate.outputs.present == 'true' - run: cargo install tauri-driver --version 2.0.5 + run: | + npm install -g appium@3 + appium driver install --source=npm appium-chromium-driver - name: Install JS dependencies if: steps.gate.outputs.present == 'true' run: pnpm install --frozen-lockfile @@ -90,7 +93,6 @@ jobs: - name: Build E2E app if: steps.gate.outputs.present == 'true' run: pnpm --filter openhuman-app test:e2e:build - # Core is linked in-process — no sidecar staging needed. - name: Run agent-review E2E spec under Xvfb if: steps.gate.outputs.present == 'true' run: | @@ -100,11 +102,10 @@ jobs: eval "$(dbus-launch --sh-syntax)" mkdir -p ~/.local/share/applications export RUST_BACKTRACE=1 - cd app - mkdir -p test/e2e/artifacts - if ! bash scripts/e2e-run-spec.sh test/e2e/specs/agent-review.spec.ts agent-review; then + mkdir -p app/test/e2e/artifacts + if ! bash app/scripts/e2e-run-session.sh test/e2e/specs/agent-review.spec.ts agent-review; then echo "First agent-review run failed; retrying once..." - bash scripts/e2e-run-spec.sh test/e2e/specs/agent-review.spec.ts agent-review-retry + bash app/scripts/e2e-run-session.sh test/e2e/specs/agent-review.spec.ts agent-review-retry fi - name: Upload E2E artifacts if: always() && steps.gate.outputs.present == 'true' @@ -113,6 +114,7 @@ jobs: name: e2e-agent-review-artifacts path: | app/test/e2e/artifacts/** - /tmp/tauri-driver-e2e-agent-review.log + /tmp/openhuman-e2e-app-*.log + /tmp/appium-e2e-*.log if-no-files-found: ignore retention-days: 7 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bf77f86e4..4f4dbb531 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,32 +1,35 @@ --- -# Dedicated end-to-end test workflow. +# End-to-end desktop test workflow. # -# Runs the WDIO desktop e2e suite against the mock backend -# (`scripts/mock-api/`) on three OSes in parallel: +# Single unified driver across all three platforms: Appium Chromium driver +# attached to CEF's already-enabled remote-debugging port (:19222). The +# per-OS driver fork (tauri-driver on Linux/Windows, Appium Mac2 on macOS) +# is gone — every platform now runs the same recipe and sees the same DOM. # -# - linux → containerized in `ghcr.io/tinyhumansai/openhuman_ci:latest` -# (the same image used by `unit-tests` and by -# `e2e/docker-compose.yml`), Xvfb-backed tauri-driver. -# CEF caveat: tauri-driver / webkit2gtk cannot drive the CEF -# WebView's DOM, so this leg runs only specs that don't depend -# on accessibility-tree queries (smoke). +# Recipe per OS: +# 1. Build the CEF app via `pnpm --filter openhuman-app test:e2e:build`. +# 2. Install Appium 3.x + the `chromium` driver. +# 3. Run `app/scripts/e2e-run-session.sh` which: +# - launches the built binary, +# - waits for CDP on :19222, +# - prunes the prewarm `about:blank` target, +# - downloads a chromedriver matching CEF's Chromium version, +# - starts Appium and runs WDIO against `wdio.conf.ts`. # -# - macos → bare runner, Appium Mac2 driver. Canonical e2e runner. -# Runs smoke + the mega-flow spec (the latter best-effort -# until the BootCheckGate local-mode bypass lands). +# Linux runs CEF inside Xvfb because there is no native display on the +# Actions ubuntu runner. # -# - windows → bare runner, tauri-driver via WebView2. Smoke only for now; -# Windows hasn't been wired up for the broader spec suite. +# The three jobs are intentionally near-identical — they only differ in +# (a) runner type, (b) container image (linux only), (c) Xvfb wrapping +# (linux only). They are kept as separate jobs (not a matrix with a +# conditional `container:` block) because GitHub Actions does not have +# a clean way to conditionally enable/disable `container:` from matrix +# expansion. # # Triggers: -# - pull_request (smoke only on every PR — cheap signal that the bundle -# still builds and launches on every OS). -# - workflow_dispatch with `full=true` to run the entire spec suite on -# macOS (the only platform where the broader specs are designed to pass -# today). -# -# All three legs are `continue-on-error` so a known-flaky platform doesn't -# block the PR — the macOS leg is the source of truth for spec correctness. +# - pull_request (smoke on every PR — cheap signal that the bundle +# still builds and launches on every OS via the unified driver). +# - workflow_dispatch with `full=true` to run the entire spec suite. name: E2E on: @@ -34,7 +37,7 @@ on: workflow_dispatch: inputs: full: - description: Run the full e2e spec suite on macOS (slow; ~30+ min) + description: Run the full e2e spec suite (slow; ~30+ min) required: false default: 'false' type: choice @@ -49,20 +52,14 @@ concurrency: cancel-in-progress: true jobs: - # --------------------------------------------------------------------------- - # Linux — build-only. tauri-driver speaks WebKitWebDriver / webkit2gtk and - # cannot drive the CEF-backed WebView, so we can't run specs against the - # Linux Tauri build yet. We still build the Linux .deb-style debug binary - # so a Linux-breaking dep / config / Rust change still fails the PR. - # Reuses the project's CI image (same one consumed by - # `e2e/docker-compose.yml` and the `unit-tests` job). - # --------------------------------------------------------------------------- e2e-linux: - name: E2E (Linux build only) + name: E2E (Linux / Appium Chromium) runs-on: ubuntu-22.04 container: image: ghcr.io/tinyhumansai/openhuman_ci:latest - timeout-minutes: 45 + timeout-minutes: 60 + # Linux runs CEF inside Xvfb on a container image — keep it + # non-blocking until we have signal that it's reliable. continue-on-error: true steps: - name: Checkout code @@ -86,7 +83,7 @@ jobs: . -> target app/src-tauri -> target cache-on-failure: true - key: e2e-linux + key: e2e-linux-unified - name: Install JS dependencies run: pnpm install --frozen-lockfile @@ -96,17 +93,47 @@ jobs: touch .env touch app/.env - - name: Build E2E app (Linux debug binary) + - name: Install Appium and chromium driver + run: | + npm install -g appium@3 + appium driver install --source=npm appium-chromium-driver + + - name: Build E2E app run: pnpm --filter openhuman-app test:e2e:build - # --------------------------------------------------------------------------- - # macOS — Appium Mac2. Canonical e2e runner. - # --------------------------------------------------------------------------- + - name: Run smoke spec (Xvfb) + run: | + xvfb-run -a --server-args="-screen 0 1280x960x24" \ + bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke + + - name: Run mega-flow spec (Xvfb, non-blocking) + continue-on-error: true + run: | + xvfb-run -a --server-args="-screen 0 1280x960x24" \ + bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow + + - name: Run full e2e suite (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.full == 'true' + run: | + xvfb-run -a --server-args="-screen 0 1280x960x24" \ + bash app/scripts/e2e-run-session.sh + + - name: Upload e2e artifacts on failure + if: failure() || cancelled() + uses: actions/upload-artifact@v5 + with: + name: e2e-artifacts-linux + path: | + app/test/e2e/artifacts/ + ${{ runner.temp }}/openhuman-e2e-app-*.log + ${{ runner.temp }}/appium-e2e-*.log + if-no-files-found: ignore + retention-days: 7 + e2e-macos: - name: E2E (macOS / Appium Mac2) + name: E2E (macOS / Appium Chromium) runs-on: macos-latest timeout-minutes: 90 - continue-on-error: true steps: - name: Checkout code uses: actions/checkout@v5 @@ -125,6 +152,24 @@ jobs: - name: Install Rust (rust-toolchain.toml) uses: dtolnay/rust-toolchain@1.93.0 + with: + # macos-latest is arm64, but the vendored tauri-cli's build.rs + # (tauri-bundler/build.rs:134) compiles a CEF helper for + # x86_64-apple-darwin to produce a universal binary, so the + # x86_64 target's libstd must be installed even though the + # final E2E binary is arm64 only. + targets: x86_64-apple-darwin + + - name: Verify cargo resolves to real toolchain + # Defensive against rustup-init being shadowed onto cargo's name in + # PATH (observed once where `cargo install --path X` printed + # "rustup-init[EXE]: unexpected argument 'install' found"). Force + # the rustup default to the requested version and print what + # `cargo` actually is. + run: | + rustup default 1.93.0 || true + which cargo + cargo --version - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 @@ -133,12 +178,10 @@ jobs: . -> target app/src-tauri -> target cache-on-failure: true - key: e2e-macos - - - name: Accept Xcode license + first launch - run: | - sudo xcodebuild -license accept - sudo xcodebuild -runFirstLaunch + # v2 suffix invalidates a cache that had a broken cargo binary + # ("error: unexpected argument 'install' found" from rustup-init + # when ensure-tauri-cli ran `cargo install --locked --path`). + key: e2e-macos-unified-v2 - name: Install JS dependencies run: pnpm install --frozen-lockfile @@ -148,48 +191,56 @@ jobs: touch .env touch app/.env - - name: Install Appium and mac2 driver + - name: Install Appium and chromium driver run: | - npm install -g appium - appium driver install mac2 + npm install -g appium@3 + appium driver install --source=npm appium-chromium-driver - - name: Build E2E app bundle + - name: Build E2E app run: pnpm --filter openhuman-app test:e2e:build - - name: Run smoke spec - run: bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke + # Adhoc-sign the unsigned debug bundle. macOS now kills processes that + # load dynamic frameworks (the CEF helper apps) from an unsigned bundle + # — the parent OpenHuman process exits within ~2s with an empty log. + # Adhoc signing (-) satisfies the loader without needing a real + # developer-ID cert. `--force --deep` re-signs the embedded helper + # apps and the framework too. + - name: Adhoc-sign the .app bundle + run: | + codesign --force --deep --sign - \ + app/src-tauri/target/debug/bundle/macos/OpenHuman.app + codesign --verify --deep --verbose=2 \ + app/src-tauri/target/debug/bundle/macos/OpenHuman.app - - name: Run mega-flow spec + - name: Run smoke spec + run: bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke + + - name: Run mega-flow spec (non-blocking) continue-on-error: true - run: bash app/scripts/e2e-run-spec.sh test/e2e/specs/mega-flow.spec.ts mega-flow + run: bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow - name: Run full e2e suite (workflow_dispatch only) if: github.event_name == 'workflow_dispatch' && github.event.inputs.full == 'true' - run: pnpm --filter openhuman-app test:e2e:all:flows + run: bash app/scripts/e2e-run-session.sh - name: Upload e2e artifacts on failure if: failure() || cancelled() uses: actions/upload-artifact@v5 with: name: e2e-artifacts-macos - path: app/test/e2e/artifacts/ + path: | + app/test/e2e/artifacts/ + ${{ runner.temp }}/openhuman-e2e-app-*.log + ${{ runner.temp }}/appium-e2e-*.log if-no-files-found: ignore retention-days: 7 - # --------------------------------------------------------------------------- - # Windows — build-only. tauri-driver on Windows uses MSEdgeDriver - # (WebView2) which can't drive the CEF-backed WebView the app actually - # ships — observed `session not created: Microsoft Edge failed to start: - # crashed.` when the driver tried to launch Edge for the WebView2 host - # that doesn't exist on our app. Same shape as the Linux block (CEF ↔ - # webkit2gtk-driver). Build the .exe so a Windows-breaking dep / config - # / Rust change still fails the PR; skip the spec run until a - # CEF-compatible Windows driver lands. - # --------------------------------------------------------------------------- e2e-windows: - name: E2E (Windows build only) + name: E2E (Windows / Appium Chromium) runs-on: windows-latest - timeout-minutes: 60 + timeout-minutes: 90 + # Windows CEF + chromedriver attach is unverified end-to-end; keep + # non-blocking until we have a green run on a real Windows runner. continue-on-error: true steps: - name: Checkout code @@ -217,11 +268,7 @@ jobs: . -> target app/src-tauri -> target cache-on-failure: true - # `v2` suffix invalidates the prior cache that ended up pinning - # stale .rmeta artifacts (saw `error[E0061]: sys.refresh_processes` - # referencing a `local_ai/install.rs` line that doesn't exist on - # this branch — cargo cross-referenced an outdated .rmeta). - key: e2e-windows-v2 + key: e2e-windows-unified - name: Install JS dependencies run: pnpm install --frozen-lockfile @@ -232,14 +279,37 @@ jobs: touch .env touch app/.env - - name: Build E2E app (Windows debug .exe) + - name: Install Appium and chromium driver + shell: bash + run: | + npm install -g appium@3 + appium driver install --source=npm appium-chromium-driver + + - name: Build E2E app run: pnpm --filter openhuman-app test:e2e:build + - name: Run smoke spec + shell: bash + run: bash app/scripts/e2e-run-session.sh test/e2e/specs/smoke.spec.ts smoke + + - name: Run mega-flow spec (non-blocking) + continue-on-error: true + shell: bash + run: bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow + + - name: Run full e2e suite (workflow_dispatch only) + if: github.event_name == 'workflow_dispatch' && github.event.inputs.full == 'true' + shell: bash + run: bash app/scripts/e2e-run-session.sh + - name: Upload e2e artifacts on failure if: failure() || cancelled() uses: actions/upload-artifact@v5 with: name: e2e-artifacts-windows - path: app/test/e2e/artifacts/ + path: | + app/test/e2e/artifacts/ + ${{ runner.temp }}/openhuman-e2e-app-*.log + ${{ runner.temp }}/appium-e2e-*.log if-no-files-found: ignore retention-days: 7 diff --git a/app/.gitignore b/app/.gitignore index 425440884..bd00b1ab7 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -25,3 +25,4 @@ dist-ssr # ESLint cache .eslintcache +app/test/e2e/.cache/ diff --git a/app/package.json b/app/package.json index 39a0158d1..09f6e9425 100644 --- a/app/package.json +++ b/app/package.json @@ -45,6 +45,8 @@ "test:e2e": "pnpm test:e2e:build && pnpm test:e2e:login && pnpm test:e2e:auth", "test:e2e:all:flows": "bash ./scripts/e2e-run-all-flows.sh", "test:e2e:all": "pnpm test:e2e:build && pnpm test:e2e:all:flows", + "test:e2e:session": "bash ./scripts/e2e-run-session.sh", + "test:e2e:session:full": "pnpm test:e2e:build && pnpm test:e2e:session", "test:all": "pnpm test:coverage && pnpm test:rust && pnpm test:e2e", "rust:check": "cargo check --manifest-path src-tauri/Cargo.toml", "rust:format": "cargo fmt --manifest-path ../Cargo.toml --all && cargo fmt --manifest-path src-tauri/Cargo.toml --all", diff --git a/app/scripts/e2e-build.sh b/app/scripts/e2e-build.sh index f44eddd42..292407547 100755 --- a/app/scripts/e2e-build.sh +++ b/app/scripts/e2e-build.sh @@ -44,25 +44,30 @@ TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}' # Tauri CLI maps env CI to --ci and only accepts true|false; some runners set CI=1. case "${CI:-}" in 1) export CI=true ;; 0) export CI=false ;; esac +# CEF runtime requires the vendored CEF-aware tauri-cli (the stock one produces +# a bundle that panics at startup in cef::library_loader::LibraryLoader::new). +# All other build scripts in app/package.json do `pnpm tauri:ensure` + use +# `cargo tauri build`; the E2E build was the one outlier and we got the panic. +pnpm tauri:ensure +export CEF_PATH="$HOME/Library/Caches/tauri-cef" + OS="$(uname)" case "$OS" in Linux) - # Linux: build debug binary only (no bundle needed for tauri-driver) + # Linux: build debug binary only. echo "Building for Linux (debug binary, no bundle)..." - pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle + cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle -- --bin OpenHuman ;; Darwin) - # macOS: build .app bundle for Appium Mac2 (wdio.conf points at + # macOS: build .app bundle (wdio.conf points at # src-tauri/target/debug/bundle/macos/OpenHuman.app). echo "Building for macOS (.app bundle)..." - pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug + cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug -- --bin OpenHuman ;; MINGW*|MSYS*|CYGWIN*|Windows_NT) - # Windows: bare .exe at src-tauri/target/debug/OpenHuman.exe is what - # wdio.conf launches via tauri-driver. NSIS/MSI bundling adds time we - # don't need for the driver path. + # Windows: bare .exe at src-tauri/target/debug/OpenHuman.exe. echo "Building for Windows (.exe, no bundle)..." - pnpm exec tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle + cargo tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle -- --bin OpenHuman ;; *) echo "ERROR: unsupported OS for e2e build: $OS" >&2 diff --git a/app/scripts/e2e-run-session.sh b/app/scripts/e2e-run-session.sh new file mode 100755 index 000000000..d7270a839 --- /dev/null +++ b/app/scripts/e2e-run-session.sh @@ -0,0 +1,503 @@ +#!/usr/bin/env bash +# +# Unified WDIO E2E runner — one Appium session, one CEF app launch, all specs. +# +# Architecture: +# 1. Build artefacts must exist (run `pnpm test:e2e:build` first). +# 2. Clean cached app data + write a fresh E2E config.toml pointing at the +# shared mock backend. +# 3. Launch the built CEF binary directly (cross-platform). +# 4. Wait for the CEF process to expose CDP on 127.0.0.1:19222. +# 5. Start Appium with the `chromium` driver. +# 6. Run wdio against `test/wdio.conf.ts`, which attaches to the running +# CEF via Appium's Chromium driver. All specs share one session. +# 7. Tear everything down (Appium → CEF → workspace). +# +# Usage: +# ./app/scripts/e2e-run-session.sh # whole suite +# ./app/scripts/e2e-run-session.sh test/e2e/specs/foo.spec.ts # single spec +# +set -euo pipefail + +SPEC_ARG="${1:-}" +LOG_SUFFIX="${2:-session}" + +E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" +CEF_CDP_PORT="${CEF_CDP_PORT:-19222}" +APPIUM_PORT="${APPIUM_PORT:-4723}" +OS="$(uname)" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$APP_DIR/.." && pwd)" +cd "$APP_DIR" + +CREATED_TEMP_WORKSPACE="" +APPIUM_PID="" +APP_PID="" +E2E_CONFIG_BACKUP="" +E2E_CONFIG_FILE="" + +# ------------------------------------------------------------------------------ +# Workspace + config +# ------------------------------------------------------------------------------ +if [ -z "${OPENHUMAN_WORKSPACE:-}" ]; then + OPENHUMAN_WORKSPACE="$(mktemp -d)" + CREATED_TEMP_WORKSPACE="$OPENHUMAN_WORKSPACE" + export OPENHUMAN_WORKSPACE + echo "[runner] Using temporary OPENHUMAN_WORKSPACE: $OPENHUMAN_WORKSPACE" +else + echo "[runner] Using OPENHUMAN_WORKSPACE from environment: $OPENHUMAN_WORKSPACE" +fi + +if [ "${OPENHUMAN_SERVICE_MOCK:-0}" = "1" ] && [ -z "${OPENHUMAN_SERVICE_MOCK_STATE_FILE:-}" ]; then + OPENHUMAN_SERVICE_MOCK_STATE_FILE="$OPENHUMAN_WORKSPACE/service-mock-state.json" + export OPENHUMAN_SERVICE_MOCK_STATE_FILE +fi + +cleanup() { + if [ -n "$APPIUM_PID" ]; then + echo "[runner] Stopping Appium (pid $APPIUM_PID)..." + kill "$APPIUM_PID" 2>/dev/null || true + wait "$APPIUM_PID" 2>/dev/null || true + fi + if [ -n "$APP_PID" ]; then + echo "[runner] Stopping CEF app (pid $APP_PID)..." + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + if [ -n "$CREATED_TEMP_WORKSPACE" ]; then + rm -rf "$CREATED_TEMP_WORKSPACE" + fi + if [ -n "$E2E_CONFIG_BACKUP" ] && [ -f "$E2E_CONFIG_BACKUP" ]; then + mv "$E2E_CONFIG_BACKUP" "$E2E_CONFIG_FILE" + elif [ -n "$E2E_CONFIG_FILE" ] && [ -f "$E2E_CONFIG_FILE" ]; then + rm -f "$E2E_CONFIG_FILE" + fi +} +trap cleanup EXIT + +export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" +export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" +export APPIUM_PORT +export CEF_CDP_PORT + +echo "[runner] Killing any running OpenHuman instances..." +case "$OS" in + Darwin) pkill -f "OpenHuman" 2>/dev/null || true ;; + Linux) pkill -f "OpenHuman" 2>/dev/null || true ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + taskkill //F //IM "OpenHuman.exe" 2>/dev/null || true + ;; +esac +sleep 1 + +echo "[runner] Cleaning cached app data..." +case "$OS" in + Darwin) + rm -rf ~/Library/WebKit/com.openhuman.app + rm -rf ~/Library/Caches/com.openhuman.app + rm -rf "$HOME/Library/Application Support/com.openhuman.app" + rm -rf "$HOME/Library/Saved Application State/com.openhuman.app.savedState" + ;; + Linux) + rm -rf "$HOME/.local/share/com.openhuman.app" 2>/dev/null || true + rm -rf "$HOME/.cache/com.openhuman.app" 2>/dev/null || true + rm -rf "$HOME/.config/com.openhuman.app" 2>/dev/null || true + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + rm -rf "${APPDATA:-$HOME/AppData/Roaming}/com.openhuman.app" 2>/dev/null || true + rm -rf "${LOCALAPPDATA:-$HOME/AppData/Local}/com.openhuman.app" 2>/dev/null || true + ;; +esac + +# Mock URL must reach the core sidecar — XCUITest doesn't inherit env, +# and CEF child processes won't either. Pinning via config.toml works +# on every platform. +E2E_CONFIG_DIR="$HOME/.openhuman" +E2E_CONFIG_FILE="$E2E_CONFIG_DIR/config.toml" +mkdir -p "$E2E_CONFIG_DIR" +if [ -f "$E2E_CONFIG_FILE" ]; then + E2E_CONFIG_BACKUP="$E2E_CONFIG_FILE.e2e-backup.$$" + cp "$E2E_CONFIG_FILE" "$E2E_CONFIG_BACKUP" + sed -i.bak '/^api_url[[:space:]]*=/d' "$E2E_CONFIG_FILE" && rm -f "$E2E_CONFIG_FILE.bak" + EXISTING_CONTENT="$(cat "$E2E_CONFIG_FILE")" + printf 'api_url = "http://127.0.0.1:%s"\n%s\n' "${E2E_MOCK_PORT}" "$EXISTING_CONTENT" > "$E2E_CONFIG_FILE" +else + printf 'api_url = "http://127.0.0.1:%s"\n' "${E2E_MOCK_PORT}" > "$E2E_CONFIG_FILE" +fi +echo "[runner] Wrote E2E config.toml with api_url=http://127.0.0.1:${E2E_MOCK_PORT}" + +DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)" +if [ -z "$DIST_JS" ]; then + echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2 + echo " Run 'pnpm test:e2e:build' first." >&2 + exit 1 +fi +if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then + echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2 + echo " Run 'pnpm test:e2e:build' to rebuild." >&2 + exit 1 +fi + +# ------------------------------------------------------------------------------ +# Resolve the built CEF binary for this platform +# ------------------------------------------------------------------------------ +resolve_app_binary() { + case "$OS" in + Darwin) + for base in \ + "$APP_DIR/src-tauri/target/debug/bundle/macos/OpenHuman.app/Contents/MacOS/OpenHuman" \ + "$REPO_ROOT/target/debug/bundle/macos/OpenHuman.app/Contents/MacOS/OpenHuman"; do + if [ -x "$base" ]; then echo "$base"; return; fi + done + ;; + Linux) + for candidate in \ + "$APP_DIR/src-tauri/target/debug/OpenHuman" \ + "$REPO_ROOT/target/debug/OpenHuman"; do + if [ -x "$candidate" ]; then echo "$candidate"; return; fi + done + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + for candidate in \ + "$APP_DIR/src-tauri/target/debug/OpenHuman.exe" \ + "$REPO_ROOT/target/debug/OpenHuman.exe"; do + if [ -x "$candidate" ]; then echo "$candidate"; return; fi + done + ;; + esac +} + +APP_BIN="$(resolve_app_binary)" +if [ -z "${APP_BIN:-}" ] || [ ! -x "$APP_BIN" ]; then + echo "ERROR: built OpenHuman binary not found. Run 'pnpm test:e2e:build' first." >&2 + exit 1 +fi + +# ------------------------------------------------------------------------------ +# Ensure a dbus session bus exists on Linux. +# +# The Tauri `single-instance` plugin (used inside OpenHuman) talks to dbus +# via zbus on Linux. If DBUS_SESSION_BUS_ADDRESS is missing or set to +# `disabled:` (which is the openhuman_ci container default), the plugin +# panics during plugin setup: +# panicked at plugins/single-instance/src/platform_impl/linux.rs:57 +# Result::unwrap() on Err(Address("unsupported transport 'disabled'")) +# So start a real session bus with `dbus-launch` and inherit its +# DBUS_SESSION_BUS_ADDRESS for the rest of the runner. +# ------------------------------------------------------------------------------ +if [ "$OS" = "Linux" ]; then + if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ] || \ + printf '%s' "${DBUS_SESSION_BUS_ADDRESS:-}" | grep -q '^disabled'; then + if command -v dbus-launch >/dev/null 2>&1; then + DBUS_LAUNCH_OUT="$(dbus-launch --sh-syntax)" + eval "$DBUS_LAUNCH_OUT" + echo "[runner] Started dbus session bus: $DBUS_SESSION_BUS_ADDRESS" + else + echo "[runner] Warning: dbus-launch not available — single-instance plugin may panic." + fi + fi +fi + +# ------------------------------------------------------------------------------ +# Make CEF runtime libraries discoverable. +# +# macOS bundles the framework into `OpenHuman.app/Contents/Frameworks/` so the +# OS resolves it automatically. On Linux + Windows we build with --no-bundle +# (faster, no .deb / .msi staging needed for tests), which means the bare +# binary has no co-located libcef.so / libcef.dll. CEF lives in the +# vendored-tauri-cli cache that `ensure-tauri-cli.sh` pinned via CEF_PATH: +# $CEF_PATH//cef__/{libcef.so,libcef.dll,Resources,…} +# +# We find the only versioned directory under CEF_PATH and prepend its CEF +# dist dir to LD_LIBRARY_PATH (Linux) / PATH (Windows). On macOS this is a +# no-op — the .app bundle already self-resolves. +# ------------------------------------------------------------------------------ +CEF_PATH="${CEF_PATH:-$HOME/Library/Caches/tauri-cef}" + +# Pick exactly one CEF distribution per platform. If two cached versions +# coexist (e.g. after a CEF upgrade) `head -1` silently picks an arbitrary +# one and the binary can load the wrong libcef — Linux/Windows then fail +# only on warmed runners. Error out so the failure is loud. +pick_one_cef_dist() { + local pattern="$1" + local matches + mapfile -t matches < <(find "$CEF_PATH" -mindepth 2 -maxdepth 2 -type d -name "$pattern" 2>/dev/null | sort) + if [ "${#matches[@]}" -gt 1 ]; then + echo "ERROR: multiple CEF distributions found under $CEF_PATH matching $pattern:" >&2 + printf ' %s\n' "${matches[@]}" >&2 + echo "Set CEF_PATH to a directory containing exactly one cef_* version." >&2 + return 1 + fi + printf '%s' "${matches[0]:-}" +} + +case "$OS" in + Linux) + CEF_DIST_DIR="$(pick_one_cef_dist 'cef_linux_*')" || exit 1 + if [ -n "$CEF_DIST_DIR" ] && [ -d "$CEF_DIST_DIR" ]; then + export LD_LIBRARY_PATH="$CEF_DIST_DIR:${LD_LIBRARY_PATH:-}" + echo "[runner] LD_LIBRARY_PATH includes: $CEF_DIST_DIR" + else + echo "[runner] Warning: no CEF Linux distribution found under $CEF_PATH — libcef.so may fail to load." + fi + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + CEF_DIST_DIR="$(pick_one_cef_dist 'cef_windows_*')" || exit 1 + if [ -n "$CEF_DIST_DIR" ] && [ -d "$CEF_DIST_DIR" ]; then + # Windows uses PATH for DLL resolution. Use the native Windows path form + # so the CEF binary itself can find libcef.dll even though we're running + # under git-bash. + if command -v cygpath >/dev/null 2>&1; then + WIN_CEF_DIST="$(cygpath -w "$CEF_DIST_DIR")" + else + WIN_CEF_DIST="$CEF_DIST_DIR" + fi + export PATH="$CEF_DIST_DIR:$PATH" + echo "[runner] PATH includes: $CEF_DIST_DIR (win: $WIN_CEF_DIST)" + else + echo "[runner] Warning: no CEF Windows distribution found under $CEF_PATH — libcef.dll may fail to load." + fi + ;; +esac + +# ------------------------------------------------------------------------------ +# Launch CEF app — CDP on 19222 is already enabled in lib.rs (see CLAUDE.md). +# ------------------------------------------------------------------------------ +LOG_DIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +APP_LOG="$LOG_DIR/openhuman-e2e-app-${LOG_SUFFIX}.log" +APP_ARGS=() +# CEF/Chromium needs extra coaxing in headless / containerized Linux runs: +# +# --no-sandbox crbug.com/638180 — refuses to start as root +# without this. The openhuman_ci docker image +# runs as uid 0. +# --disable-dev-shm-usage docker /dev/shm is often 64 MB; Chromium +# assumes ≥2 GB and crashes mid-startup +# ("Failed global descriptor lookup: 7" in the +# zygote helper). +# --disable-gpu no GPU in the CI container. +# --no-zygote skips the zygote launcher that wants dbus. +# +# Apply only on Linux. macOS/Windows runners are unprivileged users with a +# real display / GPU; leaving the sandbox on there is correct. +case "$OS" in + Linux) + if [ "$(id -u 2>/dev/null || echo 0)" = "0" ]; then + APP_ARGS+=("--no-sandbox") + fi + APP_ARGS+=( + "--disable-dev-shm-usage" + "--disable-gpu" + "--no-zygote" + ) + echo "[runner] Linux CEF args: ${APP_ARGS[*]}" + ;; +esac +echo "[runner] Launching CEF app: $APP_BIN ${APP_ARGS[*]:-}" +echo "[runner] App logs: $APP_LOG" +# `${APP_ARGS[@]+"${APP_ARGS[@]}"}` is the idiom for expanding a possibly- +# empty array under `set -u`. On macOS APP_ARGS is empty; on Linux it has +# --no-sandbox etc. Without this guard bash errors with "APP_ARGS[@]: +# unbound variable" and the app never launches. +"$APP_BIN" ${APP_ARGS[@]+"${APP_ARGS[@]}"} > "$APP_LOG" 2>&1 & +APP_PID=$! + +echo "[runner] Waiting for CDP at http://127.0.0.1:${CEF_CDP_PORT}/json/version ..." +CDP_VERSION_JSON="" +for i in $(seq 1 60); do + CDP_VERSION_JSON="$(curl -sf "http://127.0.0.1:${CEF_CDP_PORT}/json/version" 2>/dev/null || true)" + if [ -n "$CDP_VERSION_JSON" ]; then + echo "[runner] CDP is ready." + break + fi + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "ERROR: CEF app exited before CDP came up. App log follows:" >&2 + echo "----- $APP_LOG -----" >&2 + cat "$APP_LOG" >&2 || true + echo "----- end log -----" >&2 + exit 1 + fi + if [ "$i" -eq 60 ]; then + echo "ERROR: CDP did not come up within 60s. App log follows:" >&2 + echo "----- $APP_LOG -----" >&2 + cat "$APP_LOG" >&2 || true + echo "----- end log -----" >&2 + exit 1 + fi + sleep 1 +done + +# Wait for the main app target (tauri.localhost) to be visible, then close the +# CEF prewarm child-webview slot (about:blank) so chromedriver attaches to the +# real app. Without this, debuggerAddress picks the first page target — which +# is the prewarm — and chromedriver's session is bound to a target CEF may +# garbage-collect mid-test, killing the session with "session terminated". +echo "[runner] Waiting for main app CDP target (tauri.localhost) ..." +MAIN_TARGET_ID="" +for i in $(seq 1 60); do + TARGETS_JSON="$(curl -sf "http://127.0.0.1:${CEF_CDP_PORT}/json/list" 2>/dev/null || true)" + MAIN_TARGET_ID="$(printf '%s' "$TARGETS_JSON" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) +for t in data: + if t.get("type") == "page" and "tauri.localhost" in (t.get("url") or ""): + print(t.get("id", "")) + break +' 2>/dev/null || true)" + if [ -n "$MAIN_TARGET_ID" ]; then + echo "[runner] Main app target: $MAIN_TARGET_ID" + break + fi + if [ "$i" -eq 60 ]; then + echo "[runner] Warning: main app target never appeared — chromedriver may attach to the wrong target." + break + fi + sleep 1 +done + +if [ -n "$MAIN_TARGET_ID" ]; then + printf '%s' "$TARGETS_JSON" | python3 -c ' +import json, sys +try: + data = json.load(sys.stdin) +except Exception: + sys.exit(0) +for t in data: + if t.get("type") == "page" and (t.get("url") or "").startswith("about:"): + print(t.get("id", "")) +' 2>/dev/null | while read -r STALE_ID; do + if [ -n "$STALE_ID" ]; then + echo "[runner] Closing prewarm/about:blank target: $STALE_ID" + curl -sf "http://127.0.0.1:${CEF_CDP_PORT}/json/close/$STALE_ID" > /dev/null 2>&1 || true + fi + done +fi + +# ------------------------------------------------------------------------------ +# Resolve a chromedriver whose major matches CEF's bundled Chromium. +# +# chromedriver is strict about same-major-version matching (e.g. cd148 cannot +# talk to Chrome 146). The Appium chromium driver ships its own bundled +# chromedriver but that often drifts ahead of CEF's pinned Chromium. +# Solution: parse Chromium's version from /json/version, then download the +# matching chromedriver from Chrome for Testing into a workspace-local cache. +# We pass the resulting binary to Appium via the `appium:chromedriverExecutable` +# capability (wired in wdio.conf.ts via E2E_CHROMEDRIVER_PATH). +# ------------------------------------------------------------------------------ +CHROMIUM_FULL_VERSION="$(echo "$CDP_VERSION_JSON" | sed -n 's/.*"Browser": *"[^/]*\/\([^"]*\)".*/\1/p' | head -1)" +if [ -z "$CHROMIUM_FULL_VERSION" ]; then + CHROMIUM_FULL_VERSION="$(echo "$CDP_VERSION_JSON" | sed -n 's/.*"Browser": *"\([^"]*\)".*/\1/p' | sed 's/^[^/]*\///' | head -1)" +fi +echo "[runner] CEF Chromium version: ${CHROMIUM_FULL_VERSION:-}" + +case "$OS" in + Darwin) + ARCH="$(uname -m)" + case "$ARCH" in + arm64) CFT_PLATFORM="mac-arm64" ;; + x86_64) CFT_PLATFORM="mac-x64" ;; + *) CFT_PLATFORM="mac-arm64" ;; + esac + CD_EXE_NAME="chromedriver" + ;; + Linux) + CFT_PLATFORM="linux64" + CD_EXE_NAME="chromedriver" + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + CFT_PLATFORM="win64" + CD_EXE_NAME="chromedriver.exe" + ;; +esac + +CD_CACHE_DIR="$APP_DIR/test/e2e/.cache/chromedriver/${CHROMIUM_FULL_VERSION:-unknown}-${CFT_PLATFORM}" +CD_BINARY="$CD_CACHE_DIR/chromedriver-${CFT_PLATFORM}/${CD_EXE_NAME}" + +if [ -n "$CHROMIUM_FULL_VERSION" ] && [ ! -x "$CD_BINARY" ]; then + CD_URL="https://storage.googleapis.com/chrome-for-testing-public/${CHROMIUM_FULL_VERSION}/${CFT_PLATFORM}/chromedriver-${CFT_PLATFORM}.zip" + echo "[runner] Downloading matching chromedriver: $CD_URL" + mkdir -p "$CD_CACHE_DIR" + CD_ZIP="$CD_CACHE_DIR/chromedriver.zip" + if curl -fSL "$CD_URL" -o "$CD_ZIP"; then + if command -v unzip >/dev/null 2>&1; then + (cd "$CD_CACHE_DIR" && unzip -o -q chromedriver.zip) + else + # The openhuman_ci docker image doesn't ship `unzip`; use Python's + # stdlib zipfile so we don't have to add a system package install. + python3 -c "import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])" \ + "$CD_ZIP" "$CD_CACHE_DIR" + fi + chmod +x "$CD_BINARY" 2>/dev/null || true + else + echo "[runner] Warning: chromedriver $CHROMIUM_FULL_VERSION not on Chrome for Testing; falling back to Appium-bundled chromedriver." + fi +fi + +if [ -x "$CD_BINARY" ]; then + export E2E_CHROMEDRIVER_PATH="$CD_BINARY" + echo "[runner] Using chromedriver: $E2E_CHROMEDRIVER_PATH" +fi + +# ------------------------------------------------------------------------------ +# Start Appium (chromium driver) +# ------------------------------------------------------------------------------ +# shellcheck source=/dev/null +source "$SCRIPT_DIR/e2e-resolve-node-appium.sh" + +# Make sure the chromium driver is installed. `appium driver list --installed` +# exits non-zero on parse errors in some Appium versions, so just attempt the +# install and ignore "already installed" output. +echo "[runner] Ensuring Appium chromium driver is installed..." +"$APPIUM_BIN" driver install --source=npm appium-chromium-driver >/dev/null 2>&1 || true + +APPIUM_LOG="$LOG_DIR/appium-e2e-${LOG_SUFFIX}.log" + +# Fail fast if something else is already serving on the Appium port. Otherwise +# `curl /status` succeeds against the stale server while our just-launched +# Appium dies with EADDRINUSE — we'd silently drive the wrong instance. +if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then + echo "ERROR: Appium is already listening on port $APPIUM_PORT. Stop the stale server or set APPIUM_PORT." >&2 + exit 1 +fi + +echo "[runner] Starting Appium on port $APPIUM_PORT" +echo "[runner] Appium logs: $APPIUM_LOG" +"$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & +APPIUM_PID=$! + +for i in $(seq 1 30); do + # If Appium crashed between forks (e.g. unhandled driver-load error), bail + # out instead of polling /status for 30s. + if ! kill -0 "$APPIUM_PID" 2>/dev/null; then + echo "ERROR: Appium exited before becoming ready. Appium log follows:" >&2 + echo "----- $APPIUM_LOG -----" >&2 + cat "$APPIUM_LOG" >&2 || true + echo "----- end log -----" >&2 + exit 1 + fi + if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then + echo "[runner] Appium is ready." + break + fi + if [ "$i" -eq 30 ]; then + echo "ERROR: Appium did not start within 30 seconds." >&2 + exit 1 + fi + sleep 1 +done + +# ------------------------------------------------------------------------------ +# Run WDIO +# ------------------------------------------------------------------------------ +if [ -n "$SPEC_ARG" ]; then + echo "[runner] Running single spec: $SPEC_ARG" + pnpm exec wdio run test/wdio.conf.ts --spec "$SPEC_ARG" +else + echo "[runner] Running full E2E suite (single shared session)..." + pnpm exec wdio run test/wdio.conf.ts +fi diff --git a/app/scripts/e2e-run-spec.sh b/app/scripts/e2e-run-spec.sh index f4831378e..d3e2706d0 100755 --- a/app/scripts/e2e-run-spec.sh +++ b/app/scripts/e2e-run-spec.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash # -# Run a single WebDriverIO E2E spec. +# Legacy per-spec runner — kept as a thin shim that delegates to the +# unified session runner. The harness no longer has separate driver paths +# per platform (Mac2 vs tauri-driver); everything runs on Appium Chromium +# driver attached to CEF's CDP port. See e2e-run-session.sh. # -# - macOS: Appium mac2 driver (started locally, port 4723) -# - Linux: tauri-driver (started locally, port 4444; blocked for default CEF builds) -# -# Usage: +# Usage (unchanged): # ./app/scripts/e2e-run-spec.sh test/e2e/specs/login-flow.spec.ts [log-suffix] # set -euo pipefail @@ -13,204 +13,5 @@ set -euo pipefail SPEC="${1:?spec path required}" LOG_SUFFIX="${2:-$(basename "$SPEC" .spec.ts)}" -E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}" -OS="$(uname)" - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -REPO_ROOT="$(cd "$APP_DIR/.." && pwd)" -cd "$APP_DIR" - -CREATED_TEMP_WORKSPACE="" -DRIVER_PID="" - -if [ -z "${OPENHUMAN_WORKSPACE:-}" ]; then - OPENHUMAN_WORKSPACE="$(mktemp -d)" - CREATED_TEMP_WORKSPACE="$OPENHUMAN_WORKSPACE" - export OPENHUMAN_WORKSPACE - echo "Using temporary OPENHUMAN_WORKSPACE: $OPENHUMAN_WORKSPACE" -else - echo "Using OPENHUMAN_WORKSPACE from environment: $OPENHUMAN_WORKSPACE" -fi - -if [ "${OPENHUMAN_SERVICE_MOCK:-0}" = "1" ] && [ -z "${OPENHUMAN_SERVICE_MOCK_STATE_FILE:-}" ]; then - OPENHUMAN_SERVICE_MOCK_STATE_FILE="$OPENHUMAN_WORKSPACE/service-mock-state.json" - export OPENHUMAN_SERVICE_MOCK_STATE_FILE - echo "Using OPENHUMAN_SERVICE_MOCK_STATE_FILE: $OPENHUMAN_SERVICE_MOCK_STATE_FILE" -fi - -cleanup() { - if [ -n "$DRIVER_PID" ]; then - echo "Stopping driver (pid $DRIVER_PID)..." - kill "$DRIVER_PID" 2>/dev/null || true - wait "$DRIVER_PID" 2>/dev/null || true - fi - if [ -n "$CREATED_TEMP_WORKSPACE" ]; then - rm -rf "$CREATED_TEMP_WORKSPACE" - fi - # Restore original config.toml (or remove the E2E one) - if [ -n "${E2E_CONFIG_BACKUP:-}" ] && [ -f "$E2E_CONFIG_BACKUP" ]; then - mv "$E2E_CONFIG_BACKUP" "$E2E_CONFIG_FILE" - echo "Restored original config.toml" - elif [ -n "${E2E_CONFIG_FILE:-}" ] && [ -f "${E2E_CONFIG_FILE:-}" ]; then - rm -f "$E2E_CONFIG_FILE" - echo "Removed E2E config.toml" - fi -} -trap cleanup EXIT - -export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" -export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}" - -echo "Killing any running OpenHuman instances..." -if [ "$OS" = "Darwin" ]; then - pkill -f "OpenHuman" 2>/dev/null || true - # Give the process time to exit and release file locks - sleep 1 -fi - -echo "Cleaning cached app data..." -if [ "$OS" = "Darwin" ]; then - rm -rf ~/Library/WebKit/com.openhuman.app - rm -rf ~/Library/Caches/com.openhuman.app - rm -rf "$HOME/Library/Application Support/com.openhuman.app" - rm -rf "$HOME/Library/Saved Application State/com.openhuman.app.savedState" -else - rm -rf "$HOME/.local/share/com.openhuman.app" 2>/dev/null || true - rm -rf "$HOME/.cache/com.openhuman.app" 2>/dev/null || true - rm -rf "$HOME/.config/com.openhuman.app" 2>/dev/null || true -fi - -# Write config.toml into the default ~/.openhuman/ so the core process -# uses the mock server URL. Appium Mac2 launches the .app via XCUITest -# which does NOT inherit shell environment variables, so BACKEND_URL -# never reaches the embedded core process. Writing api_url to the config file -# is the reliable cross-platform approach. -E2E_CONFIG_DIR="$HOME/.openhuman" -E2E_CONFIG_FILE="$E2E_CONFIG_DIR/config.toml" -E2E_CONFIG_BACKUP="" -mkdir -p "$E2E_CONFIG_DIR" -if [ -f "$E2E_CONFIG_FILE" ]; then - E2E_CONFIG_BACKUP="$E2E_CONFIG_FILE.e2e-backup.$$" - cp "$E2E_CONFIG_FILE" "$E2E_CONFIG_BACKUP" - echo "Backed up existing config.toml to $E2E_CONFIG_BACKUP" - # Remove any existing api_url line and prepend the mock URL - sed -i.bak '/^api_url[[:space:]]*=/d' "$E2E_CONFIG_FILE" && rm -f "$E2E_CONFIG_FILE.bak" - EXISTING_CONTENT="$(cat "$E2E_CONFIG_FILE")" - printf 'api_url = "http://127.0.0.1:%s"\n%s\n' "${E2E_MOCK_PORT}" "$EXISTING_CONTENT" > "$E2E_CONFIG_FILE" -else - cat > "$E2E_CONFIG_FILE" <&2 - echo " Run 'pnpm test:e2e:build' to build the app before running E2E tests." >&2 - exit 1 -fi -if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then - echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2 - echo " Run 'pnpm test:e2e:build' to rebuild with the mock URL." >&2 - exit 1 -fi -if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then - echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2 - echo " Run 'pnpm test:e2e:build' to rebuild with the mock URL." >&2 - exit 1 -fi -echo "Verified: frontend bundle contains mock server URL." - -case "$OS" in - Linux|MINGW*|MSYS*|CYGWIN*|Windows_NT) - # ------------------------------------------------------------------------- - # Linux + Windows: start tauri-driver (WebKitWebDriver/Linux, - # MSEdgeDriver/Windows under the hood). - # ------------------------------------------------------------------------- - export TAURI_DRIVER_PORT="${TAURI_DRIVER_PORT:-4444}" - case "$OS" in - MINGW*|MSYS*|CYGWIN*|Windows_NT) - DRIVER_LOG="${TMP:-${TEMP:-${TMPDIR:-/tmp}}}/tauri-driver-e2e-${LOG_SUFFIX}.log" - TAURI_DRIVER_NAME="tauri-driver.exe" - ;; - *) - DRIVER_LOG="/tmp/tauri-driver-e2e-${LOG_SUFFIX}.log" - TAURI_DRIVER_NAME="tauri-driver" - ;; - esac - - TAURI_DRIVER_BIN="$(command -v "$TAURI_DRIVER_NAME" 2>/dev/null || true)" - if [ -z "${TAURI_DRIVER_BIN:-}" ] || [ ! -x "$TAURI_DRIVER_BIN" ]; then - # Prefer the POSIX $HOME/.cargo/bin path so `-x` works in git-bash on - # Windows. Only fall back to %USERPROFILE% (converted to a POSIX path - # via cygpath if available) when $HOME doesn't have the binary — - # that covers cases where the runner's HOME is overridden. - TAURI_DRIVER_BIN="$HOME/.cargo/bin/$TAURI_DRIVER_NAME" - if [ ! -x "$TAURI_DRIVER_BIN" ] && [ -n "${USERPROFILE:-}" ]; then - if command -v cygpath >/dev/null 2>&1; then - TAURI_DRIVER_BIN="$(cygpath -u "$USERPROFILE")/.cargo/bin/$TAURI_DRIVER_NAME" - else - TAURI_DRIVER_BIN="$USERPROFILE/.cargo/bin/$TAURI_DRIVER_NAME" - fi - fi - fi - if [ ! -x "$TAURI_DRIVER_BIN" ]; then - echo "ERROR: $TAURI_DRIVER_NAME not found. Install with: cargo install tauri-driver" >&2 - exit 1 - fi - - echo "Starting tauri-driver on port $TAURI_DRIVER_PORT..." - echo " Driver logs: $DRIVER_LOG" - "$TAURI_DRIVER_BIN" --port "$TAURI_DRIVER_PORT" > "$DRIVER_LOG" 2>&1 & - DRIVER_PID=$! - - for i in $(seq 1 15); do - if curl -sf "http://127.0.0.1:$TAURI_DRIVER_PORT/status" >/dev/null 2>&1; then - echo "tauri-driver is ready." - break - fi - if [ "$i" -eq 15 ]; then - echo "ERROR: tauri-driver did not start within 15 seconds." >&2 - cat "$DRIVER_LOG" >&2 - exit 1 - fi - sleep 1 - done - ;; - Darwin) - # ------------------------------------------------------------------------- - # macOS: start Appium Mac2. - # ------------------------------------------------------------------------- - export APPIUM_PORT="${APPIUM_PORT:-4723}" - # shellcheck source=/dev/null - source "$SCRIPT_DIR/e2e-resolve-node-appium.sh" - - APPIUM_LOG="/tmp/appium-e2e-${LOG_SUFFIX}.log" - NODE_VER=$("$NODE24" --version) - echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." - echo " Appium logs: $APPIUM_LOG" - "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & - DRIVER_PID=$! - - for i in $(seq 1 30); do - if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then - echo "Appium is ready." - break - fi - if [ "$i" -eq 30 ]; then - echo "ERROR: Appium did not start within 30 seconds." >&2 - exit 1 - fi - sleep 1 - done - ;; - *) - echo "ERROR: unsupported OS for e2e: $OS" >&2 - exit 1 - ;; -esac - -echo "Running E2E spec ($SPEC)..." -pnpm exec wdio run test/wdio.conf.ts --spec "$SPEC" +exec "$SCRIPT_DIR/e2e-run-session.sh" "$SPEC" "$LOG_SUFFIX" diff --git a/app/test/e2e/helpers/app-helpers.ts b/app/test/e2e/helpers/app-helpers.ts index 41fcd7d6c..79c9b47ab 100644 --- a/app/test/e2e/helpers/app-helpers.ts +++ b/app/test/e2e/helpers/app-helpers.ts @@ -1,25 +1,41 @@ /** * Cross-platform app lifecycle helpers for E2E tests. * - * ## Appium Mac2 (macOS) - * XCUITest launches the .app bundle. The app starts with visible:false - * (tray app) — only the menu bar is visible until a deep link shows the window. - * Readiness is detected by polling the accessibility tree element count. + * The harness is unified onto Appium Chromium driver attached to CEF's + * remote-debugging (CDP) port on macOS / Linux / Windows. The session + * exposes the WebView DOM directly — standard CSS selectors, `el.click()`, + * and `browser.execute(...)` all work as in a normal browser session. * - * ## tauri-driver (Linux) - * tauri-driver launches the debug binary directly and exposes the WebView - * DOM via W3C WebDriver. Readiness is detected by checking document state - * and the presence of the React root element. + * Readiness checks use `document.readyState` + React-root presence; + * the old Mac2 accessibility-tree polling is gone. */ import { isTauriDriver } from './platform'; /** * Wait for the app process to be ready. - * The app starts with a hidden window, so we just wait for the process - * to initialize (driver has already launched it). + * + * The runner script has already launched the CEF binary and confirmed CDP + * is responding on :19222 before WDIO connects, so by the time a spec runs + * we usually just need to give the React root a beat to mount. Specs that + * need a stricter guarantee should call `waitForAppReady` directly. */ export async function waitForApp(): Promise { - await browser.pause(5_000); + try { + await waitForAppReady(15_000); + } catch (error) { + // Only swallow genuine readiness timeouts (the error waitForAppReady + // throws when the DOM never settles in the budget). Anything else — + // session terminated, executeScript not supported, the DOM crashed — + // surfaces with full context instead of being hidden behind a blind + // 5s pause. + const message = error instanceof Error ? error.message : String(error); + if (!message.includes('waitForAppReady timed out')) { + throw error; + } + // Fall back to the legacy fixed pause so specs that historically tolerated + // a slow startup don't regress. + await browser.pause(5_000); + } } /** diff --git a/app/test/e2e/helpers/core-rpc.ts b/app/test/e2e/helpers/core-rpc.ts index b36b5c17a..4a809ec74 100644 --- a/app/test/e2e/helpers/core-rpc.ts +++ b/app/test/e2e/helpers/core-rpc.ts @@ -1,10 +1,20 @@ /** - * Core JSON-RPC for E2E: WebView execute on tauri-driver (Linux), Node fetch on Appium Mac2. + * Core JSON-RPC for E2E. + * + * Always uses the Node-side HTTP path now. The old WebView path used + * `await import('@tauri-apps/api/core')` inside `browser.execute` to fetch + * the RPC URL — that only worked under tauri-driver because Tauri injects + * an import map into the renderer. Under the unified Appium Chromium + * driver (which speaks plain CDP to CEF), the dynamic specifier resolves + * to nothing and the call fails with "Failed to resolve module specifier + * '@tauri-apps/api/core'". + * + * The Node path probes the core's port range, attaches the per-launch + * bearer token written to `${tmpdir}/openhuman-e2e-rpc-token`, and does + * the JSON-RPC over plain HTTP — fully driver-agnostic. */ import { callOpenhumanRpcNode } from './core-rpc-node'; import type { RpcCallResult } from './core-rpc-webview'; -import { callOpenhumanRpcWebView } from './core-rpc-webview'; -import { supportsExecuteScript } from './platform'; export type { RpcCallResult }; @@ -12,8 +22,5 @@ export async function callOpenhumanRpc( method: string, params: Record = {} ): Promise> { - if (supportsExecuteScript()) { - return callOpenhumanRpcWebView(method, params); - } return callOpenhumanRpcNode(method, params); } diff --git a/app/test/e2e/helpers/platform.ts b/app/test/e2e/helpers/platform.ts index 7d96bb435..6e06f9af2 100644 --- a/app/test/e2e/helpers/platform.ts +++ b/app/test/e2e/helpers/platform.ts @@ -1,57 +1,38 @@ /** * Platform detection utilities for cross-platform E2E tests. * - * Two automation backends are supported: + * The harness was previously split between Appium Mac2 (accessibility tree) + * and tauri-driver (DOM). It is now unified onto **Appium Chromium driver + * attached to CEF's CDP port** on all three platforms (macOS / Linux / + * Windows). Every session exposes the full DOM and supports W3C + * `executeScript`, so the legacy branching collapses to a single backend. * - * - **Appium Mac2** (macOS): Drives the `.app` bundle via XCUITest / accessibility - * tree. Elements are XCUIElementType* nodes; clicks require W3C pointer actions - * because accessibility clicks don't propagate to WKWebView DOM handlers. - * - * - **tauri-driver** (Linux): WebDriver server shipped by the Tauri project. - * Exposes the WebView DOM directly — standard CSS selectors and `el.click()` - * work as in a normal browser session. + * These functions are kept (rather than deleted) as a shim so the ~40 + * existing specs that branch on `isTauriDriver()` / `isMac2()` still pass: + * they always take the DOM-capable code path now. */ /** - * Returns `true` when the session is driven by tauri-driver (Linux E2E). - * - * tauri-driver does not set `platformName` or `appium:automationName`, so the - * absence of Mac2 markers is the signal. We also check `process.platform` as - * a secondary indicator. + * Always true — the unified Chromium-driver session exposes the WebView DOM + * exactly like the old tauri-driver path did. Specs that gated DOM work + * behind `if (isTauriDriver())` now run that work on every platform. */ export function isTauriDriver(): boolean { - if (typeof browser === 'undefined') return process.platform === 'linux'; - - const caps = browser.capabilities as Record; - const automation = String( - caps['appium:automationName'] ?? caps['automationName'] ?? '' - ).toLowerCase(); - - // Appium Mac2 always sets automationName to 'mac2' - if (automation === 'mac2' || automation.includes('mac2')) return false; - - const platform = String(caps.platformName ?? caps['appium:platformName'] ?? '').toLowerCase(); - - // If platformName is 'mac' it's Appium on macOS even without automationName - if (platform === 'mac') return false; - return true; } /** - * Returns `true` when the session is driven by Appium Mac2 (macOS E2E). + * Always false. The Mac2 accessibility-tree backend is retired; macOS now + * speaks CDP just like Linux/Windows. */ export function isMac2(): boolean { - return !isTauriDriver(); + return false; } /** - * Returns `true` when the WebDriver session supports W3C Execute Script - * for running JS inside the WebView. - * - * - tauri-driver: YES (full W3C WebDriver compliance) - * - Appium Mac2: NO (only supports `macos: *` extension commands) + * Always true — Chromium driver is a full W3C WebDriver and supports + * `browser.execute(...)` on every platform. */ export function supportsExecuteScript(): boolean { - return isTauriDriver(); + return true; } diff --git a/app/test/wdio.conf.ts b/app/test/wdio.conf.ts index 79cdf59b4..ddee7f450 100644 --- a/app/test/wdio.conf.ts +++ b/app/test/wdio.conf.ts @@ -1,116 +1,133 @@ import type { Options } from '@wdio/types'; -import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { captureFailureArtifacts } from './e2e/helpers/artifacts'; +/** + * Unified WDIO config — single Appium Chromium-driver session that attaches + * to the running CEF app over its remote-debugging port (CDP). + * + * One automation backend on every platform: + * + * macOS / Linux / Windows → Appium Chromium driver → CEF :19222 + * + * The runner script (`scripts/e2e-run-session.sh`) is responsible for: + * 1. Launching the built CEF app binary. + * 2. Waiting until `http://127.0.0.1:19222/json/version` responds (CDP up). + * 3. Starting Appium with the `chromium` driver installed. + * 4. Invoking `wdio` against this config. + * + * WDIO creates ONE session per worker. With `maxInstances: 1` and no + * cross-spec teardown, all specs run sequentially in the same session, + * against the same app process — no restart cost between spec files. + * Tests are intentionally order-dependent: state from spec N flows into + * spec N+1. Each spec is responsible for any reset it requires. + */ + const configDir = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(configDir, '..'); -const repoRoot = path.resolve(projectRoot, '..'); const tsconfigE2ePath = path.join(projectRoot, 'test', 'tsconfig.e2e.json'); const testSpecsPath = path.join(projectRoot, 'test', 'e2e', 'specs', '**', '*.spec.ts'); -/** - * Resolve the path to the built Tauri application. - * - * - macOS: .app bundle for Appium Mac2 - * - Linux: debug binary for tauri-driver - * - Windows: .exe for tauri-driver - */ -function getAppPath(): string { - const bundleBases = [ - path.join(projectRoot, 'src-tauri', 'target', 'debug', 'bundle'), - path.join(repoRoot, 'target', 'debug', 'bundle'), - ]; +const APPIUM_PORT = parseInt(process.env.APPIUM_PORT || '4723', 10); +const CEF_CDP_HOST = process.env.CEF_CDP_HOST || '127.0.0.1'; +const CEF_CDP_PORT = parseInt(process.env.CEF_CDP_PORT || '19222', 10); - switch (process.platform) { - case 'darwin': { - for (const base of bundleBases) { - const appPath = path.join(base, 'macos', 'OpenHuman.app'); - if (fs.existsSync(appPath)) { - return appPath; - } - } - return path.join(bundleBases[0], 'macos', 'OpenHuman.app'); - } - case 'win32': - return path.join(projectRoot, 'src-tauri', 'target', 'debug', 'OpenHuman.exe'); - case 'linux': { - // tauri-driver launches the binary directly (not a bundle). - // Prefer the Tauri build output (src-tauri/target) over the repo-root - // target/ which may contain a stale core-only binary. - const candidates = [ - path.join(projectRoot, 'src-tauri', 'target', 'debug', 'OpenHuman'), - path.join(repoRoot, 'target', 'debug', 'OpenHuman'), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) return candidate; - } - return candidates[0]; - } - default: - throw new Error(`Unsupported platform: ${process.platform}`); - } +// appium-chromium-driver advertises support for platformName ∈ {windows, mac, linux} +// (not "chromium" — that's only the automationName). Pick the actual OS so the +// capability negotiation succeeds. +function platformNameForHost(): 'mac' | 'linux' | 'windows' { + if (process.platform === 'darwin') return 'mac'; + if (process.platform === 'win32') return 'windows'; + return 'linux'; } -/** - * Build capabilities for the current platform. - * - * - Linux: tauri-driver (W3C WebDriver, port 4444) - * - Windows: tauri-driver (W3C WebDriver, port 4444) — same as Linux, - * launches the bare .exe rather than a bundle. - * - macOS: Appium Mac2 (XCUITest, port 4723) - */ -function getPlatformCapabilities(): Record[] { - if (process.platform === 'linux' || process.platform === 'win32') { - return [{ 'tauri:options': { application: getAppPath() } }]; - } - - // macOS: Appium Mac2 - return [ - { - platformName: 'mac', - 'appium:automationName': 'Mac2', - 'appium:app': getAppPath(), - 'appium:showServerLogs': true, - }, - ]; -} - -/** Port for the automation driver: tauri-driver (4444) or Appium (4723). */ -const isTauriDriverHost = process.platform === 'linux' || process.platform === 'win32'; -const driverPort = isTauriDriverHost - ? parseInt(process.env.TAURI_DRIVER_PORT || '4444', 10) - : parseInt(process.env.APPIUM_PORT || '4723', 10); - export const config: Options.Testrunner & Record = { runner: 'local', hostname: '127.0.0.1', - port: driverPort, + port: APPIUM_PORT, + path: '/', specs: [testSpecsPath], rootDir: projectRoot, - maxInstances: 1, // Tauri apps are single-instance - capabilities: getPlatformCapabilities(), + // Single session — Tauri+CEF is one app instance. + maxInstances: 1, + capabilities: [ + { + platformName: platformNameForHost(), + 'appium:automationName': 'Chromium', + // The runner downloads a chromedriver whose major matches CEF's + // bundled Chromium and exports its path here. If unset, Appium falls + // back to its bundled chromedriver — which usually drifts ahead of + // CEF and produces a "ChromeDriver only supports Chrome version N" + // session-creation error. + // + // Appium chromium driver names this capability `executable` (see + // appium-chromium-driver/build/lib/desired-caps.js), not the more + // common Chrome-driver name `chromedriverExecutable`. + ...(process.env.E2E_CHROMEDRIVER_PATH + ? { 'appium:executable': process.env.E2E_CHROMEDRIVER_PATH } + : {}), + 'goog:chromeOptions': { + // Attach to the already-running CEF process. chromedriver will not + // try to launch its own Chrome — it picks the first page target + // exposed at this address (which is the main OpenHuman webview). + debuggerAddress: `${CEF_CDP_HOST}:${CEF_CDP_PORT}`, + }, + }, + ], logLevel: 'warn', bail: 0, waitforTimeout: 10_000, - // Linux tauri-driver can take longer to establish the initial session on - // loaded CI runners; keep macOS defaults while giving Linux more headroom. - connectionRetryTimeout: isTauriDriverHost ? 240_000 : 120_000, - connectionRetryCount: isTauriDriverHost ? 5 : 3, - // No appium/tauri-driver service — driver is started externally via scripts. + connectionRetryTimeout: 120_000, + connectionRetryCount: 3, framework: 'mocha', reporters: ['spec'], mochaOpts: { ui: 'bdd', - timeout: 120_000, // Billing/settings tests need extra time for API polling + // Billing/settings flows poll on real timers; keep the generous budget. + timeout: 120_000, }, autoCompileOpts: { tsNodeOpts: { project: tsconfigE2ePath } }, /** - * Always capture screenshot + page source on failure so agents can - * inspect what the app looked like the moment the assertion failed. + * After the chromedriver session attaches, switch the active window to + * the main OpenHuman app webview. + * + * CEF exposes multiple CDP page targets: + * - `about:blank` — the CEF prewarm hot-loaded child-webview slot + * (see CEF_PREWARM_LABEL in src-tauri/src/lib.rs). + * - `OpenHuman` @ `http://tauri.localhost/#/` — the main React app. + * + * `debuggerAddress` makes chromedriver attach to the *first* page target, + * which is `about:blank`. Without this switch, every spec ends up looking + * at an empty document. We pick the first window whose URL contains + * `tauri.localhost`, falling back to the first non-`about:blank`. */ + before: async function () { + const handles = await browser.getWindowHandles(); + let target: string | null = null; + for (const handle of handles) { + await browser.switchToWindow(handle); + const url = await browser.getUrl(); + if (url.includes('tauri.localhost')) { + target = handle; + break; + } + } + if (!target) { + for (const handle of handles) { + await browser.switchToWindow(handle); + const url = await browser.getUrl(); + if (!url.startsWith('about:')) { + target = handle; + break; + } + } + } + if (target) { + await browser.switchToWindow(target); + } + }, afterTest: async function ( test: { title: string; parent?: string }, _context: unknown,