From 70b36be43129babb685a643bb8a53ad89b689e94 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:35:46 +0200 Subject: [PATCH] feat(ci): add blocking desktop E2E smoke test (MAT-135) A new e2e-smoke CI job builds the real debug binary (tauri-plugin-pilot compiled in) with an E2E CSP overlay, launches it under xvfb with an empty temporary profile, and drives the critical path through the actual UI and Tauri IPC: paste a link served by a local deterministic fixture server (HEAD + Range/206), resolve it in the Link Grabber, start the download, wait for completion in the Downloads view, then verify the file's name, size, and SHA-256. The app is restarted to prove the completed download persists through SQLite. On failure the job uploads a screenshot, page HTML, console and app logs, and the temp profile data as diagnostic artifacts. --- .github/workflows/ci.yml | 62 +++++++++ .gitignore | 3 + CHANGELOG.md | 10 ++ scripts/e2e/fixture-server.py | 103 ++++++++++++++ scripts/e2e/smoke.sh | 150 +++++++++++++++++++++ scripts/e2e/tauri.e2e.conf.json | 7 + src/views/DownloadsView/DownloadsTable.tsx | 10 +- src/views/LinkGrabberView/ActionsBar.tsx | 2 +- src/views/LinkGrabberView/PasteZone.tsx | 3 +- 9 files changed, 347 insertions(+), 3 deletions(-) create mode 100755 scripts/e2e/fixture-server.py create mode 100755 scripts/e2e/smoke.sh create mode 100644 scripts/e2e/tauri.e2e.conf.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a235f01..0f723b92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -243,6 +243,68 @@ jobs: if-no-files-found: warn retention-days: 7 + e2e-smoke: + name: E2E smoke (desktop) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libappindicator3-dev \ + librsvg2-dev \ + patchelf \ + xvfb + + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: npm ci + run: npm ci + + - name: Cache tauri-pilot CLI + id: cache-pilot + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cargo/bin/tauri-pilot + key: tauri-pilot-cli-0.7.2-${{ runner.os }} + + - name: Install tauri-pilot CLI + if: steps.cache-pilot.outputs.cache-hit != 'true' + run: cargo install tauri-pilot-cli --version 0.7.2 --locked + + - name: Build debug app (pilot plugin + E2E CSP overlay) + run: npx tauri build --debug --no-bundle --config scripts/e2e/tauri.e2e.conf.json + + - name: Run E2E smoke + env: + WEBKIT_DISABLE_COMPOSITING_MODE: "1" + WEBKIT_DISABLE_DMABUF_RENDERER: "1" + run: xvfb-run -a ./scripts/e2e/smoke.sh + + - name: Upload E2E diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-smoke-diagnostics + path: e2e-artifacts/ + if-no-files-found: warn + retention-days: 7 + changelog-check: name: CHANGELOG updated runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d1c09247..745a6e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ mockup.html .codex AGENT.md +# E2E smoke diagnostics (generated by scripts/e2e/smoke.sh) +e2e-artifacts/ + # Coverage reports (generated by CI, not committed) coverage/ node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 47fe5180..5ea3582e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -151,6 +151,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Blocking desktop E2E smoke test (MAT-135)**: a new `e2e-smoke` CI job + builds the real desktop binary (debug profile, tauri-plugin-pilot embedded), + launches it under xvfb with an empty temporary profile, and drives the + critical path through the actual UI and Tauri IPC: paste a link served by a + local deterministic fixture server (HEAD + Range/206), resolve it in the + Link Grabber, start the download, and wait for completion in the Downloads + view. The downloaded file is checked against its expected name, size, and + SHA-256, and the app is restarted to verify the completed download persists + through SQLite. On failure the job uploads a screenshot, page HTML, console + and app logs, and the temp profile data as diagnostic artifacts. - **Gallery end-to-end integration (MAT-134)**: pasting a supported gallery URL (Imgur, Flickr, generic pages) into the Link Grabber now expands it into one selectable row per image, preserving gallery order, filenames, and the diff --git a/scripts/e2e/fixture-server.py b/scripts/e2e/fixture-server.py new file mode 100755 index 00000000..d68502b0 --- /dev/null +++ b/scripts/e2e/fixture-server.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Local deterministic HTTP fixture for the desktop E2E smoke test (MAT-135). + +Serves /fixture.bin: 4 MiB of a fixed byte pattern. Supports HEAD, +full GET, and single-range GET -> 206 (the download engine errors out +if a server ignores its Range header). +""" + +import hashlib +import re +import sys +import threading +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +SIZE = 4 * 1024 * 1024 +BODY = bytes(range(256)) * (SIZE // 256) +SHA256 = hashlib.sha256(BODY).hexdigest() +RANGE_RE = re.compile(r"bytes=(\d+)-(\d*)$") + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _headers(self, status, length, extra=()): + self.send_response(status) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(length)) + for k, v in extra: + self.send_header(k, v) + self.end_headers() + + def do_HEAD(self): + if self.path != "/fixture.bin": + self.send_error(404) + return + self._headers(200, SIZE) + + def do_GET(self): + if self.path != "/fixture.bin": + self.send_error(404) + return + m = RANGE_RE.match(self.headers.get("Range", "")) + if m: + start = int(m.group(1)) + end = int(m.group(2)) if m.group(2) else SIZE - 1 + if start >= SIZE or end < start: + self.send_error(416) + return + end = min(end, SIZE - 1) + chunk = BODY[start : end + 1] + self._headers( + 206, len(chunk), [("Content-Range", f"bytes {start}-{end}/{SIZE}")] + ) + self.wfile.write(chunk) + else: + self._headers(200, SIZE) + self.wfile.write(BODY) + + +def serve(port): + server = ThreadingHTTPServer(("127.0.0.1", port), Handler) + return server + + +def self_test(): + server = serve(0) + port = server.server_address[1] + threading.Thread(target=server.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{port}/fixture.bin" + + head = urllib.request.urlopen(urllib.request.Request(base, method="HEAD")) + assert head.status == 200 and int(head.headers["Content-Length"]) == SIZE + assert head.headers["Accept-Ranges"] == "bytes" + + full = urllib.request.urlopen(base).read() + assert hashlib.sha256(full).hexdigest() == SHA256 + + req = urllib.request.Request(base, headers={"Range": "bytes=100-259"}) + part = urllib.request.urlopen(req) + assert part.status == 206 + assert part.headers["Content-Range"] == f"bytes 100-259/{SIZE}" + assert part.read() == BODY[100:260] + + try: + urllib.request.urlopen(f"http://127.0.0.1:{port}/nope") + raise AssertionError("expected 404") + except urllib.error.HTTPError as e: + assert e.code == 404 + + server.shutdown() + print(f"self-test OK (sha256={SHA256})") + + +if __name__ == "__main__": + if "--self-test" in sys.argv: + self_test() + else: + srv = serve(int(sys.argv[1])) + print(f"fixture server on 127.0.0.1:{srv.server_address[1]}", flush=True) + srv.serve_forever() diff --git a/scripts/e2e/smoke.sh b/scripts/e2e/smoke.sh new file mode 100755 index 00000000..305be85e --- /dev/null +++ b/scripts/e2e/smoke.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# Desktop E2E smoke (MAT-135): drives the real debug binary (tauri-plugin-pilot +# is compiled in on debug builds) through the critical path — add link, resolve, +# download from a local fixture server, verify content, restart, verify persistence. +set -euo pipefail + +cd "$(dirname "$0")/../.." + +VORTEX_BIN="${VORTEX_BIN:-src-tauri/target/debug/vortex}" +PORT="${FIXTURE_PORT:-8642}" +FIXTURE_URL="http://127.0.0.1:$PORT/fixture.bin" +ART="e2e-artifacts" +# Printed by `fixture-server.py --self-test` (4 MiB of bytes(range(256)) repeated). +EXPECTED_SHA="2b07811057df887086f06a67edc6ebf911de8b6741156e7a2eb1416a4b8b1b2e" +EXPECTED_SIZE=4194304 + +if [ ! -x "$VORTEX_BIN" ]; then + echo "missing $VORTEX_BIN — run: npx tauri build --debug --no-bundle --config scripts/e2e/tauri.e2e.conf.json" >&2 + exit 1 +fi + +rm -rf "$ART" +mkdir -p "$ART" + +# Empty, isolated profile (R-02). XDG_RUNTIME_DIR also isolates the pilot +# socket, so a locally running dev instance cannot collide with the test. +# The profile must live directly under /tmp: the socket path is capped at +# ~108 bytes (SUN_LEN) and a deep directory overflows it. +PROFILE=$(mktemp -d /tmp/vxe2e.XXXXXX) +mkdir -p "$PROFILE/Downloads" "$PROFILE/.config" "$PROFILE/.local/share" \ + "$PROFILE/.cache" "$PROFILE/runtime" +chmod 700 "$PROFILE/runtime" +printf 'XDG_DOWNLOAD_DIR="$HOME/Downloads"\n' > "$PROFILE/.config/user-dirs.dirs" + +export HOME="$PROFILE" +export XDG_CONFIG_HOME="$PROFILE/.config" +export XDG_DATA_HOME="$PROFILE/.local/share" +export XDG_CACHE_HOME="$PROFILE/.cache" +export XDG_RUNTIME_DIR="$PROFILE/runtime" +export GDK_BACKEND=x11 +export TAURI_PILOT_SOCKET="$XDG_RUNTIME_DIR/tauri-pilot-dev.vortex.app.sock" + +app_pid="" +server_pid="" + +collect_artifacts() { + tauri-pilot screenshot "$ART/failure.png" 2>/dev/null || true + tauri-pilot html > "$ART/page.html" 2>&1 || true + tauri-pilot logs > "$ART/console.log" 2>&1 || true + cp -r "$XDG_DATA_HOME/dev.vortex.app" "$ART/profile-data" 2>/dev/null || true +} + +cleanup() { + status=$? + if [ "$status" -ne 0 ]; then + collect_artifacts + echo "SMOKE FAILED — diagnostics in $ART/" >&2 + fi + if [ -n "$app_pid" ]; then kill "$app_pid" 2>/dev/null || true; fi + if [ -n "$server_pid" ]; then kill "$server_pid" 2>/dev/null || true; fi + wait 2>/dev/null || true + # WebKit helper processes may still write to the profile briefly after the + # app is killed; retry once instead of failing the run on the race. + rm -rf "$PROFILE" 2>/dev/null || { sleep 2; rm -rf "$PROFILE" || true; } + exit "$status" +} +trap cleanup EXIT + +launch_app() { + "$VORTEX_BIN" > "$ART/$1" 2>&1 & + app_pid=$! + for _ in $(seq 1 60); do + if tauri-pilot ping >/dev/null 2>&1; then return 0; fi + if ! kill -0 "$app_pid" 2>/dev/null; then + echo "app exited during startup — see $ART/$1" >&2 + return 1 + fi + sleep 1 + done + echo "pilot socket never became ready — see $ART/$1" >&2 + return 1 +} + +# Poll for a CSS selector via eval. The pilot `wait` command relies on an +# in-page promise that silently times out when the element appears after the +# call, so polling is the reliable primitive here. +wait_for() { # + local sel=$1 deadline=$(($(date +%s) + $2)) + while [ "$(date +%s)" -lt "$deadline" ]; do + if [ "$(tauri-pilot eval "document.querySelector('$sel') ? 'yes' : 'no'" 2>/dev/null)" = "yes" ]; then + return 0 + fi + sleep 1 + done + echo "timeout waiting for: $sel" >&2 + return 1 +} + +echo "--- fixture server" +python3 scripts/e2e/fixture-server.py --self-test > "$ART/fixture-selftest.log" 2>&1 +python3 scripts/e2e/fixture-server.py "$PORT" > "$ART/fixture-server.log" 2>&1 & +server_pid=$! +for _ in $(seq 1 20); do + curl -sfI "$FIXTURE_URL" >/dev/null 2>&1 && break + sleep 0.5 +done +curl -sfI "$FIXTURE_URL" >/dev/null + +echo "--- launch app (empty profile)" +launch_app app.log +wait_for 'a[href="/link-grabber"]' 30 + +echo "--- add link + resolve in Link Grabber" +tauri-pilot click 'a[href="/link-grabber"]' +wait_for '[data-testid=paste-input]' 15 +# `fill` uses the HTMLInputElement value setter, which throws on a