From ef4ae5af1c8c3f52d721f2346f4273834048d39b Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 14 May 2026 21:02:14 -0700 Subject: [PATCH] fix(e2e/linux): silence dead-session noise + local docker harness refresh (#1777) --- .github/workflows/e2e.yml | 8 +- app/test/e2e/helpers/deep-link-helpers.ts | 183 ++++++++++++++++------ e2e/docker-compose.yml | 65 ++++++-- e2e/docker-local-bootstrap.sh | 67 ++++++++ e2e/run-local.sh | 70 +++++++++ 5 files changed, 325 insertions(+), 68 deletions(-) create mode 100755 e2e/docker-local-bootstrap.sh create mode 100755 e2e/run-local.sh diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4f4dbb531..634a622b2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -118,8 +118,12 @@ jobs: xvfb-run -a --server-args="-screen 0 1280x960x24" \ bash app/scripts/e2e-run-session.sh - - name: Upload e2e artifacts on failure - if: failure() || cancelled() + - name: Upload e2e artifacts + # `always()` so artifacts upload even when an earlier step is + # `continue-on-error: true` (mega-flow) and the job is marked + # success. Without this we have no logs to diagnose the Linux + # CEF session-death issue. + if: always() uses: actions/upload-artifact@v5 with: name: e2e-artifacts-linux diff --git a/app/test/e2e/helpers/deep-link-helpers.ts b/app/test/e2e/helpers/deep-link-helpers.ts index 1b7d9c7b0..8b90b0027 100644 --- a/app/test/e2e/helpers/deep-link-helpers.ts +++ b/app/test/e2e/helpers/deep-link-helpers.ts @@ -1,14 +1,26 @@ /** * Deep-link trigger utilities for E2E tests. * - * ## tauri-driver (Linux — preferred CI path) - * `browser.execute()` is fully supported, so `window.__simulateDeepLink()` is - * the primary strategy. Shell fallback uses `xdg-open`. + * All three platforms now run under Appium chromium driver attached to CEF, + * which supports W3C `browser.execute()`. The primary path is therefore + * `window.__simulateDeepLink()` injected into the WebView. * - * ## Appium Mac2 (macOS — local dev path) - * Mac2 does NOT support W3C Execute Script in WKWebView. Strategies (in order): - * 1. `macos: activateApp` + `macos: deepLink` extension commands - * 2. Shell `open -a ... "url"` fallback + * Strategy order: + * 1. WebView `__simulateDeepLink()` — works on every platform. + * 2. macOS-only `macos: launchApp` + `macos: deepLink` extension commands + * (kept for the macOS shell-out path that lets the OS dispatch the URL + * scheme through Launch Services). + * 3. macOS shell `open -a … "url"`. + * + * Linux has no shell fallback: `xdg-open openhuman://…` requires a + * `.desktop` file registering the URL scheme, which the CI container does + * not have, so attempting it just produces noise. If the WebView simulate + * fails on Linux, `triggerDeepLink` throws immediately. + * + * When the WebDriver session has been torn down (`A session is either + * terminated or not started`), every fallback also fails with the same + * error — so we detect that case once via `isSessionDeadError` and rethrow + * a clear message instead of letting the cascade of retries spam the log. */ import * as fs from 'fs'; import * as path from 'path'; @@ -32,6 +44,26 @@ function execCommand(command: string): Promise { }); } +/** + * A "session is either terminated or not started" error from the WebDriver + * client means CEF/Appium has already dropped the connection. Retrying or + * falling back to other strategies is pointless — every subsequent call + * will produce the same error (which is what caused the ~hundred-line + * WARN/ERROR cascade in the Linux job we're trying to fix). Detect it + * once, fail fast, and surface a clean error. + */ +function isSessionDeadError(err: unknown): boolean { + if (!err) return false; + const message = err instanceof Error ? err.message : String(err); + // First two patterns are what WebDriver / Appium raise directly; the + // third matches our own wrapped error (rethrown from inner helpers) so + // an outer catcher still recognises the dead-session case after we + // replace the message for clarity. + return /session is either terminated or not started|invalid session id|WebDriver session is dead/i.test( + message + ); +} + /** * Check if the WebDriver session supports `browser.execute()` for running * JS inside the WebView. @@ -66,6 +98,15 @@ async function trySimulateDeepLinkInWebView(url: string): Promise { if (ping !== true) return false; } catch (err) { deepLinkDebug('execute ping failed', err instanceof Error ? err.message : err); + // Bubble up dead-session so the caller can short-circuit the + // macOS / xdg-open fallback chain instead of spamming the log + // with identical "session terminated" errors for every retry. + if (isSessionDeadError(err)) { + throw new Error( + `WebDriver session is dead — cannot deliver deep link ${url}. ` + + `The CEF app or driver likely crashed in an earlier step.` + ); + } return false; } @@ -85,18 +126,38 @@ async function trySimulateDeepLinkInWebView(url: string): Promise { poll += 1; } catch (err) { deepLinkDebug('ready check failed', err instanceof Error ? err.message : err); + // Same rationale as the ping check above: once the session is gone + // there's nothing to recover to. Bubble up so triggerDeepLink can + // skip the macOS / Linux fallbacks instead of returning a generic + // `false` that hides the root cause. + if (isSessionDeadError(err)) { + throw new Error( + `WebDriver session is dead — cannot deliver deep link ${url}. ` + + `The CEF app or driver likely crashed in an earlier step.` + ); + } return false; } if (ready) { deepLinkDebug('invoking window.__simulateDeepLink'); - await browser.execute(async (u: string) => { - const w = window as Window & { __simulateDeepLink?: (x: string) => Promise }; - if (!w.__simulateDeepLink) { - throw new Error('__simulateDeepLink is not available'); + try { + await browser.execute(async (u: string) => { + const w = window as Window & { __simulateDeepLink?: (x: string) => Promise }; + if (!w.__simulateDeepLink) { + throw new Error('__simulateDeepLink is not available'); + } + await w.__simulateDeepLink(u); + }, url); + } catch (err) { + if (isSessionDeadError(err)) { + throw new Error( + `WebDriver session is dead — cannot deliver deep link ${url}. ` + + `The CEF app or driver likely crashed in an earlier step.` + ); } - await w.__simulateDeepLink(u); - }, url); + throw err; + } deepLinkDebug('simulate deep link finished OK'); return true; } @@ -140,52 +201,76 @@ export async function triggerDeepLink(url: string): Promise { }); if (typeof browser !== 'undefined') { - // Strategy 1: WebView simulate (works on tauri-driver, skipped on Mac2) - if (await trySimulateDeepLinkInWebView(url)) { - deepLinkDebug('deep link delivered via WebView simulate'); - return; + // Strategy 1: WebView simulate — the only reliable path on the unified + // CEF/Appium-Chromium harness. If it succeeds we're done; if it throws + // a dead-session error there's nothing more to try (the macOS extension + // commands and shell fallback both require a live driver too, or in + // Linux's case a `.desktop` file registering the `openhuman://` scheme + // that the CI container doesn't have). + try { + if (await trySimulateDeepLinkInWebView(url)) { + deepLinkDebug('deep link delivered via WebView simulate'); + return; + } + } catch (err) { + // Dead-session: rethrow with the clear message so WDIO reports the + // real cause instead of a "Failed to trigger deep link: xdg-open" + // red herring. + if (isSessionDeadError(err)) throw err; + // Other errors (e.g. JS exception inside the page): keep trying + // the platform-specific fallbacks below. + deepLinkDebug( + 'WebView simulate threw, continuing to fallbacks', + err instanceof Error ? err.message : err + ); } - try { - await browser.execute('macos: launchApp', { - bundleId: 'com.openhuman.app', - arguments: [url], - } as Record); - deepLinkDebug('macos: launchApp OK'); - } catch (err) { - deepLinkDebug('macos: launchApp failed', err instanceof Error ? err.message : err); - } - for (let attempt = 1; attempt <= 3; attempt += 1) { + // Strategy 2: macOS-only extension commands. Skip outright on Linux — + // they always fail there with the same "session terminated" cascade + // we're trying to silence. + if (process.platform === 'darwin') { try { - await browser.execute('macos: deepLink', { url, bundleId: 'com.openhuman.app' } as Record< - string, - unknown - >); - deepLinkDebug('macos: deepLink OK', { attempt }); - await browser.pause(300); - return; + await browser.execute('macos: launchApp', { + bundleId: 'com.openhuman.app', + arguments: [url], + } as Record); + deepLinkDebug('macos: launchApp OK'); } catch (err) { - deepLinkDebug('macos: deepLink failed', { - attempt, - error: err instanceof Error ? err.message : err, - }); - await browser.pause(250); + if (isSessionDeadError(err)) throw err; + deepLinkDebug('macos: launchApp failed', err instanceof Error ? err.message : err); + } + for (let attempt = 1; attempt <= 3; attempt += 1) { + try { + await browser.execute('macos: deepLink', { url, bundleId: 'com.openhuman.app' } as Record< + string, + unknown + >); + deepLinkDebug('macos: deepLink OK', { attempt }); + await browser.pause(300); + return; + } catch (err) { + if (isSessionDeadError(err)) throw err; + deepLinkDebug('macos: deepLink failed', { + attempt, + error: err instanceof Error ? err.message : err, + }); + await browser.pause(250); + } } } } // Strategy 3: Shell fallback if (process.platform === 'linux') { - // On Linux, use xdg-open for URL scheme dispatch - try { - deepLinkDebug('fallback shell: xdg-open', url); - await execCommand(`xdg-open "${url}"`); - deepLinkDebug('deep link dispatched via xdg-open'); - return; - } catch (err) { - deepLinkDebug('xdg-open failed', err instanceof Error ? err.message : err); - throw new Error(`Failed to trigger deep link: ${err instanceof Error ? err.message : err}`); - } + // The Linux CI container does not register `openhuman://` with + // xdg-mime, so `xdg-open` cannot dispatch the URL — it just errors + // with `Command failed`. The only deep-link path that works under + // CEF/Appium-Chromium on Linux is the in-WebView simulate above; if + // we got here it already failed, and there is nothing to recover to. + throw new Error( + `Failed to trigger deep link ${url}: WebView simulate failed and ` + + `xdg-open is not a valid fallback on Linux (no .desktop registration).` + ); } // macOS shell fallback diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 7f09a9a2f..5549a7b77 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -1,38 +1,69 @@ ## -# Run Linux E2E tests locally from macOS via Docker. +# Run the Linux E2E flow locally on macOS via Docker. +# +# This mirrors the `e2e-linux` job in `.github/workflows/e2e.yml` so any +# fix that turns the local run green is what CI will run too: +# - same image: ghcr.io/tinyhumansai/openhuman_ci:latest +# - same Xvfb + dbus bootstrap (via e2e/docker-entrypoint.sh) +# - same runner: app/scripts/e2e-run-session.sh (Appium chromium driver +# attached to CEF on :19222), NOT the retired tauri-driver path. # # Usage: -# # Build + run all E2E flows -# docker compose -f e2e/docker-compose.yml run --rm e2e -# -# # Run a specific spec +# # Build the E2E app once (slow — produces the CEF bundle): # docker compose -f e2e/docker-compose.yml run --rm e2e \ -# bash app/scripts/e2e-run-spec.sh test/e2e/specs/smoke.spec.ts smoke +# bash -lc "pnpm install --frozen-lockfile && pnpm --filter openhuman-app test:e2e:build" # -# # Build the E2E app first (if not already built) +# # Then run a spec (smoke / mega-flow / etc): # docker compose -f e2e/docker-compose.yml run --rm e2e \ -# yarn workspace openhuman-app test:e2e:build +# bash -lc "bash app/scripts/e2e-run-session.sh test/e2e/specs/mega-flow.spec.ts mega-flow" +# +# # Or run the full suite: +# docker compose -f e2e/docker-compose.yml run --rm e2e \ +# bash -lc "bash app/scripts/e2e-run-session.sh" # # Notes: -# - Uses the same CI image from GHCR (built by .github/workflows/docker-ci-image.yml) -# - The repo is bind-mounted at /app so builds persist between runs -# - Rust target/ and node_modules/ are cached via named volumes -# - Xvfb provides a virtual display for webkit2gtk rendering +# - The repo is bind-mounted at /workspace so builds + edits round-trip. +# - Rust target/ and pnpm/npm caches live in named volumes so warm +# re-runs are fast. +# - Appium 3 + appium-chromium-driver are installed on first entry by +# the entrypoint (cached into the npm volume after that). # services: e2e: - image: ghcr.io/tinyhumansai/openhuman_ci:latest - entrypoint: ["/docker-entrypoint.sh"] - command: ["yarn", "workspace", "openhuman-app", "test:e2e:all"] - working_dir: /app + image: ${OPENHUMAN_CI_IMAGE:-ghcr.io/tinyhumansai/openhuman_ci:latest} + # Use the bind-mounted local bootstrap (Xvfb + dbus + Appium + pnpm). + # The image's baked-in /docker-entrypoint.sh is the CI version; the + # local wrapper additionally lazy-installs Appium and JS deps so a + # fresh checkout becomes runnable with one command. + entrypoint: ["/workspace/e2e/docker-local-bootstrap.sh"] + # Default to an interactive shell so the user can `docker compose run` + # without an explicit command. CI-style one-shot invocations override + # this on the command line. + command: ["bash", "-l"] + working_dir: /workspace volumes: - - ..:/app + - ../:/workspace - e2e-cargo-registry:/usr/local/cargo/registry - e2e-cargo-git:/usr/local/cargo/git + - e2e-rust-target:/workspace/target + - e2e-tauri-target:/workspace/app/src-tauri/target + - e2e-pnpm-store:/root/.local/share/pnpm/store + - e2e-npm-global:/usr/lib/node_modules environment: - DISPLAY=:99 - CI=true + # Appium chromium driver downloads a chromedriver matching CEF's + # Chromium version; cache it next to the repo so re-runs are fast. + - WDIO_CHROMEDRIVER_CACHE=/workspace/.cache/chromedriver + # CEF spawns a lot of helper processes; the default IPC namespace + # sometimes runs out of shm. Bump it to match what GitHub-hosted + # ubuntu-22.04 runners give CEF. + shm_size: 2gb volumes: e2e-cargo-registry: e2e-cargo-git: + e2e-rust-target: + e2e-tauri-target: + e2e-pnpm-store: + e2e-npm-global: diff --git a/e2e/docker-local-bootstrap.sh b/e2e/docker-local-bootstrap.sh new file mode 100755 index 000000000..2d02dd3e4 --- /dev/null +++ b/e2e/docker-local-bootstrap.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Local-only bootstrap for the Linux E2E Docker container. +# +# The CI image (`ghcr.io/tinyhumansai/openhuman_ci:latest`) intentionally +# does NOT bake in Appium or pnpm deps so the image stays small. CI +# installs them per-job. This wrapper performs the same install steps the +# CI workflow runs (`.github/workflows/e2e.yml` → `e2e-linux`), but only +# when they're missing — so warm re-runs are instant. +# +# Sequence: +# 1. Hand off to the shipped entrypoint (Xvfb + dbus). +# 2. Install Appium 3 + appium-chromium-driver if missing. +# 3. Install JS deps (`pnpm install --frozen-lockfile`) if missing. +# 4. exec the user's command. +# +# Triggered by `e2e/docker-compose.yml` as the container entrypoint +# wrapper. The image's own /docker-entrypoint.sh is invoked from here +# so Xvfb/dbus are still set up. +# +set -euo pipefail + +# 1. Run the image's entrypoint logic (Xvfb + dbus) in-process via `source` +# so the resulting env vars (DISPLAY, DBUS_SESSION_BUS_ADDRESS) reach +# our exec below. The shipped script ends with `exec "$@"` so we use a +# trimmed inline version here. +export DISPLAY="${DISPLAY:-:99}" +if ! pgrep -x Xvfb >/dev/null 2>&1; then + Xvfb "$DISPLAY" -screen 0 1280x1024x24 & + XVFB_PID=$! + for _ in 1 2 3 4 5; do + sleep 0.3 + kill -0 "$XVFB_PID" 2>/dev/null && break + done + if ! kill -0 "$XVFB_PID" 2>/dev/null; then + echo "[e2e-bootstrap] ERROR: Xvfb failed to start" >&2 + exit 1 + fi +fi +if [ -z "${DBUS_SESSION_BUS_ADDRESS:-}" ]; then + eval "$(dbus-launch --sh-syntax)" +fi +export RUST_BACKTRACE=1 +mkdir -p "${HOME}/.local/share/applications" + +# 2. Appium + chromium driver — install once, cache via the npm_global volume. +if ! command -v appium >/dev/null 2>&1; then + echo "[e2e-bootstrap] Installing Appium 3 + chromium driver..." + npm install -g appium@3 >/dev/null + appium driver install --source=npm appium-chromium-driver >/dev/null +fi + +# 3. JS deps — only run pnpm install when node_modules is empty or stale. +# `pnpm install --frozen-lockfile` is a no-op when up to date, so this +# is cheap on warm re-runs. +cd /workspace +if [ ! -d node_modules ] || [ ! -e node_modules/.modules.yaml ]; then + echo "[e2e-bootstrap] Installing JS deps..." + pnpm install --frozen-lockfile +fi + +# 4. Ensure stub env files exist (CI does this too). +[ -f .env ] || touch .env +[ -f app/.env ] || touch app/.env + +echo "[e2e-bootstrap] Ready (DISPLAY=$DISPLAY)." +exec "$@" diff --git a/e2e/run-local.sh b/e2e/run-local.sh new file mode 100755 index 000000000..376c33ce7 --- /dev/null +++ b/e2e/run-local.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# One-shot wrapper: reproduce the Linux E2E job locally on macOS. +# +# Mirrors `.github/workflows/e2e.yml` `e2e-linux` step-for-step inside +# the same CI container so any fix that lands green here lands green on CI. +# +# Usage: +# ./e2e/run-local.sh # smoke spec +# ./e2e/run-local.sh mega-flow # mega-flow spec +# ./e2e/run-local.sh smoke mega-flow # both, in order +# ./e2e/run-local.sh all # full suite +# ./e2e/run-local.sh shell # interactive shell +# ./e2e/run-local.sh build # rebuild CEF bundle only +# +# The first run is slow (pulls the image, installs Appium, builds the +# CEF bundle). Subsequent runs are fast — volumes cache everything. +# +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +COMPOSE=(docker compose -f e2e/docker-compose.yml) +RUN=("${COMPOSE[@]}" run --rm e2e) + +ensure_built() { + if [ ! -d "$REPO_ROOT/app/src-tauri/target/debug/bundle" ]; then + echo "[run-local] CEF bundle not built yet — building (slow first run)..." + "${RUN[@]}" bash -lc "pnpm --filter openhuman-app test:e2e:build" + fi +} + +run_spec() { + local name="$1" + local spec + case "$name" in + smoke) spec="test/e2e/specs/smoke.spec.ts" ;; + mega-flow) spec="test/e2e/specs/mega-flow.spec.ts" ;; + *) spec="test/e2e/specs/${name}.spec.ts" ;; + esac + echo "[run-local] === $name ($spec) ===" + "${RUN[@]}" bash -lc "bash app/scripts/e2e-run-session.sh '$spec' '$name'" +} + +case "${1:-smoke}" in + shell) + "${RUN[@]}" bash -l + ;; + build) + "${RUN[@]}" bash -lc "pnpm --filter openhuman-app test:e2e:build" + ;; + all) + ensure_built + "${RUN[@]}" bash -lc "bash app/scripts/e2e-run-session.sh" + ;; + *) + ensure_built + # `case "${1:-smoke}"` only defaults the test for matching — the loop + # below iterates the real `"$@"`, which is empty on a no-arg invocation. + # Re-set the positional params so a bare `./e2e/run-local.sh` actually + # runs the smoke spec instead of silently doing nothing. + if [ "$#" -eq 0 ]; then + set -- smoke + fi + for name in "$@"; do + run_spec "$name" + done + ;; +esac