diff --git a/dev/e2e/README.md b/dev/e2e/README.md index 6432f27..6544964 100644 --- a/dev/e2e/README.md +++ b/dev/e2e/README.md @@ -14,6 +14,11 @@ Log in from the app with **server** `http://10.0.2.2:8090` (Android emulator) or `http://127.0.0.1:8090` (Expo web / `adb reverse tcp:8090 tcp:8090`), **user** `e2e`, **password** `E2eTest#2026pass`. +Runs on native Linux (cargo, curl, ffmpeg with `drawtext`, exiftool) or in +WSL driving the Windows toolchain; the script detects which. If port 8090 is +taken, pick another with `E2E_PORT=8091` (the app's `drive-app` skill reads +the same variable). + ## Isolation It runs next to a real local server without touching it: diff --git a/dev/e2e/check_wall_clock.py b/dev/e2e/check_wall_clock.py old mode 100644 new mode 100755 diff --git a/dev/e2e/e2e.sh b/dev/e2e/e2e.sh old mode 100644 new mode 100755 index 74eb330..285982c --- a/dev/e2e/e2e.sh +++ b/dev/e2e/e2e.sh @@ -3,14 +3,14 @@ # # Runs a second, fully isolated server next to any real one: its own build # dir (target/e2e — a running target/debug exe is file-locked on Windows), -# its own process name (image-api-e2e.exe, so `stop` can never touch the -# real server), its own port, database, thumbnails and HLS output. The -# media library is a *copy* of the pristine fixtures, so tests may upload, -# move and delete freely; `reset` restores the known baseline. +# its own process name (image-api-e2e, so `stop` can never touch the real +# server), its own port, database, thumbnails and HLS output. The media +# library is a *copy* of the pristine fixtures, so tests may upload, move +# and delete freely; `reset` restores the known baseline. # -# Written for WSL driving the Windows toolchain (cargo.exe, curl.exe, -# ffmpeg.exe). WSL2's default NAT networking can't reach Windows' -# 127.0.0.1, hence curl.exe for every HTTP call. +# Works on native Linux, and in WSL driving the Windows toolchain +# (cargo.exe, curl.exe, ffmpeg.exe). WSL2's default NAT networking can't +# reach Windows' 127.0.0.1, hence curl.exe for every HTTP call there. # # Usage: dev/e2e/e2e.sh (see `help`) set -euo pipefail @@ -21,7 +21,14 @@ WORK="$HERE/.work" FIXTURES="$WORK/fixtures" STATE="$WORK/state" BIN_DIR="$WORK/bin" -EXE_NAME="image-api-e2e.exe" + +# WSL drives the Windows toolchain; anything else uses native tools. +if grep -qi microsoft /proc/version 2>/dev/null && command -v cargo.exe >/dev/null; then + WSL=1; EXT=".exe"; CARGO=cargo.exe; CURL=curl.exe +else + WSL=0; EXT=""; CARGO=cargo; CURL=curl +fi +EXE_NAME="image-api-e2e$EXT" LOG="$WORK/server.log" E2E_PORT="${E2E_PORT:-8090}" @@ -33,23 +40,30 @@ E2E_USER="${E2E_USER:-e2e}" E2E_PASS="${E2E_PASS:-E2eTest#2026pass}" URL="http://127.0.0.1:$E2E_PORT" -win() { wslpath -w "$1"; } +# A path as the server sees it (Windows path for the WSL-driven exe). +win() { if [ "$WSL" = 1 ]; then wslpath -w "$1"; else printf '%s' "$1"; fi; } +# Join a directory from win() with a file name. +win_join() { if [ "$WSL" = 1 ]; then printf '%s\\%s' "$(win "$1")" "$2"; else printf '%s/%s' "$1" "$2"; fi; } log() { printf '\033[1;34m[e2e]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[e2e]\033[0m %s\n' "$*" >&2; exit 1; } is_running() { - tasklist.exe /FI "IMAGENAME eq $EXE_NAME" /NH 2>/dev/null | grep -qi "$EXE_NAME" + if [ "$WSL" = 1 ]; then + tasklist.exe /FI "IMAGENAME eq $EXE_NAME" /NH 2>/dev/null | grep -qi "$EXE_NAME" + else + pgrep -x "$EXE_NAME" >/dev/null + fi } cmd_build() { log "building bins into target/e2e" - (cd "$REPO" && cargo.exe build --target-dir target/e2e --bin image-api --bin create_user) + (cd "$REPO" && $CARGO build --target-dir target/e2e --bin image-api --bin create_user) mkdir -p "$BIN_DIR" # Run from a copy so rebuilding never fights a running (locked) exe. local was_running=0 if is_running; then was_running=1; cmd_stop; fi - cp "$REPO/target/e2e/debug/image-api.exe" "$BIN_DIR/$EXE_NAME" - cp "$REPO/target/e2e/debug/create_user.exe" "$BIN_DIR/create_user.exe" + cp "$REPO/target/e2e/debug/image-api$EXT" "$BIN_DIR/$EXE_NAME" + cp "$REPO/target/e2e/debug/create_user$EXT" "$BIN_DIR/create_user$EXT" if [ "$was_running" = 1 ]; then cmd_start; fi } @@ -74,7 +88,7 @@ cmd_fixtures() { write_env() { cat > "$STATE/.env" </dev/null \ | grep -o '"token":"[^"]*"' | cut -d'"' -f4 } @@ -131,7 +145,7 @@ login() { api() { # api METHOD PATH [JSON]; reuses $E2E_TOKEN when set (login is a bcrypt verify) local tok="${E2E_TOKEN:-$(login)}" # /dev/null + if [ "$WSL" = 1 ]; then + taskkill.exe /IM "$EXE_NAME" /F >/dev/null + else + pkill -x "$EXE_NAME" + fi for _ in $(seq 1 20); do is_running || break; sleep 0.5; done else log "not running" @@ -210,7 +228,7 @@ cmd_status() { cmd_info() { cat < $URL Login: $E2E_USER / $E2E_PASS diff --git a/dev/e2e/gen_fixtures.py b/dev/e2e/gen_fixtures.py old mode 100644 new mode 100755 index bb2b624..95dd114 --- a/dev/e2e/gen_fixtures.py +++ b/dev/e2e/gen_fixtures.py @@ -8,8 +8,9 @@ in a single exiftool batch. A `manifest.json` next to the media records what was generated and what the server should derive from it, so tests can assert against it instead of hardcoding counts. -The harness runs in WSL but the tools (ffmpeg.exe / exiftool.exe) are -Windows binaries, so every path handed to them goes through `wslpath -w`. +Runs on native Linux with ffmpeg / exiftool, or in WSL driving the Windows +binaries (ffmpeg.exe / exiftool.exe), in which case every path handed to +them goes through `wslpath -w`. Usage: gen_fixtures.py [--import DIR] /media/ the pristine library (never served directly) @@ -30,11 +31,48 @@ from dataclasses import dataclass, field from pathlib import Path from zoneinfo import ZoneInfo -FFMPEG = shutil.which("ffmpeg.exe") or shutil.which("ffmpeg") + + +def _find_ffmpeg() -> str | None: + """The first ffmpeg that can burn labels in (has `drawtext`, i.e. was + built with freetype). E2E_FFMPEG overrides. Minimal builds earlier on + PATH (e.g. a custom one in ~/bin) often lack it.""" + if os.environ.get("E2E_FFMPEG"): + return os.environ["E2E_FFMPEG"] + candidates = [shutil.which("ffmpeg.exe"), shutil.which("ffmpeg"), "/usr/bin/ffmpeg"] + for cand in dict.fromkeys(c for c in candidates if c and os.path.exists(c)): + try: + filters = subprocess.run( + [cand, "-hide_banner", "-filters"], capture_output=True, text=True + ).stdout + except OSError: + continue + if " drawtext " in filters: + return cand + return None + + +FFMPEG = _find_ffmpeg() EXIFTOOL = shutil.which("exiftool.exe") or shutil.which("exiftool") -# drawtext needs an explicit font on Windows ffmpeg builds. The colon after -# the drive letter must be escaped inside a filter argument. -FONT = "C\\:/Windows/Fonts/consola.ttf" + +def _font() -> str: + """A monospace font for ffmpeg's drawtext, as a filter-escaped path.""" + if FFMPEG and FFMPEG.endswith(".exe"): + # Windows ffmpeg needs an explicit font; the colon after the drive + # letter must be escaped inside a filter argument. + return "C\\:/Windows/Fonts/consola.ttf" + try: + found = subprocess.check_output( + ["fc-match", "-f", "%{file}", "monospace"], text=True + ).strip() + if found: + return found + except (OSError, subprocess.CalledProcessError): + pass + return "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf" + + +FONT = _font() # The owner's display zone and the server zone the e2e env pins (TZ=...). # Naive fixture datetimes without GPS are walls in this zone. @@ -85,6 +123,10 @@ class Item: def win(p: Path) -> str: + """`p` as the external tools see it: a Windows path for WSL-driven + .exe tools, unchanged for native ones.""" + if not (FFMPEG or "").endswith(".exe"): + return str(p) return subprocess.check_output(["wslpath", "-w", str(p)], text=True).strip() @@ -288,7 +330,11 @@ def main() -> int: help="also copy this folder (read-only source) into media/imported/") a = ap.parse_args() if not FFMPEG or not EXIFTOOL: - print("ffmpeg(.exe) and exiftool(.exe) must be on PATH", file=sys.stderr) + print( + "ffmpeg(.exe) with the drawtext filter (built with freetype) and " + "exiftool(.exe) must be on PATH; set E2E_FFMPEG to pick an ffmpeg", + file=sys.stderr, + ) return 1 out: Path = a.out_dir.resolve()