fix(e2e/linux): silence dead-session noise + local docker harness refresh (#1777)

This commit is contained in:
Steven Enamakel
2026-05-14 21:02:14 -07:00
committed by GitHub
parent 6cb82a36ad
commit ef4ae5af1c
5 changed files with 325 additions and 68 deletions
+6 -2
View File
@@ -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
+134 -49
View File
@@ -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<void> {
});
}
/**
* 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<boolean> {
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<boolean> {
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<void> };
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<void> };
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<void> {
});
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<string, unknown>);
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<string, unknown>);
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
+48 -17
View File
@@ -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:
+67
View File
@@ -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 "$@"
+70
View File
@@ -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