Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions scripts/e2e/fixture-server.py
Original file line number Diff line number Diff line change
@@ -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()
150 changes: 150 additions & 0 deletions scripts/e2e/smoke.sh
Original file line number Diff line number Diff line change
@@ -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() { # <selector> <timeout-seconds>
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 <textarea>;
# the paste zone is uncontrolled (ref-read), so a plain value assignment works.
tauri-pilot eval "document.querySelector('[data-testid=paste-input]').value = '$FIXTURE_URL'"
tauri-pilot click '[data-testid=analyze-links]'
wait_for '[data-testid^="link-row-"][data-status="online"]' 30

echo "--- start download, wait for completion"
tauri-pilot click '[data-testid=start-all-online]'
# Starting stays on the Link Grabber; the Downloads view is where completion shows.
tauri-pilot click 'a[href="/downloads"]'
wait_for '[data-testid=download-row][data-state=Completed]' 120

echo "--- verify downloaded file (R-03)"
FILE="$HOME/Downloads/fixture.bin"
if [ ! -f "$FILE" ]; then
echo "downloaded file missing: $FILE" >&2
exit 1
fi
size=$(stat -c%s "$FILE")
if [ "$size" != "$EXPECTED_SIZE" ]; then
echo "size mismatch: got $size, expected $EXPECTED_SIZE" >&2
exit 1
fi
echo "$EXPECTED_SHA $FILE" | sha256sum -c -

echo "--- restart app, verify persistence (R-04)"
kill -TERM "$app_pid"
wait "$app_pid" 2>/dev/null || true
app_pid=""
launch_app app-restart.log
wait_for '[data-testid=download-row][data-state=Completed]' 30
tauri-pilot eval "document.querySelector('[data-testid=download-row][data-state=Completed]').textContent" \
| grep -q "fixture.bin"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

echo "SMOKE OK"
7 changes: 7 additions & 0 deletions scripts/e2e/tauri.e2e.conf.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"app": {
"security": {
"csp": "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self'; img-src 'self' data: blob:"
}
}
}
10 changes: 9 additions & 1 deletion src/views/DownloadsView/DownloadsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,15 @@ function SortableRow({

return (
<DragHandleContext value={ctxValue}>
<tr ref={setRef} data-index={dataIndex} style={style} className={className} onClick={onClick}>
<tr
ref={setRef}
data-testid="download-row"
data-state={state}
data-index={dataIndex}
style={style}
className={className}
onClick={onClick}
>
{children}
</tr>
</DragHandleContext>
Expand Down
2 changes: 1 addition & 1 deletion src/views/LinkGrabberView/ActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function ActionsBar({
{t("linkGrabber.actions.startSelected", { count: selectedCount })}
</Button>
)}
<Button onClick={onStartAll} variant="secondary" size="sm">
<Button data-testid="start-all-online" onClick={onStartAll} variant="secondary" size="sm">
{t("linkGrabber.actions.startAllOnline")}
</Button>
<Button onClick={onClearAll} variant="destructive" size="sm">
Expand Down
Loading
Loading