chore(e2e): run the e2e dev server on native Linux #172

Merged
cameron merged 2 commits from feat/e2e-native-linux into master 2026-10-16 19:26:42 +00:00
4 changed files with 97 additions and 28 deletions

No files matched your search

+5
View File
@@ -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:
Regular → Executable
View File
File mode changed.
Regular → Executable
+39 -21
View File
@@ -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 <command> (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" <<EOF
# Generated by dev/e2e/e2e.sh — edit dev/e2e/env.local instead (appended below).
DATABASE_URL='$(win "$STATE")\\e2e.db'
DATABASE_URL='$(win_join "$STATE" e2e.db)'
BASE_PATH='$(win "$STATE/media")'
THUMBNAILS='$(win "$STATE/thumbs")'
VIDEO_PATH='$(win "$STATE/hls")'
@@ -96,7 +110,7 @@ WATCH_QUICK_INTERVAL_SECONDS=3
WATCH_FULL_INTERVAL_SECONDS=60
EXCLUDED_DIRS=/private
HLS_CONCURRENCY=2
USER_NAME=E2E Tester
USER_NAME='E2E Tester'
REEL_PREGEN_ENABLED=0
REEL_CACHE_SWEEP_ENABLED=0
# AI / sibling services are off by default: point Ollama at a closed port so
@@ -118,12 +132,12 @@ cmd_reset() {
cp -a "$FIXTURES/media" "$STATE/media"
cp "$FIXTURES/manifest.json" "$STATE/manifest.json"
write_env
[ -x "$BIN_DIR/create_user.exe" ] || cmd_build
(cd "$STATE" && "$BIN_DIR/create_user.exe" "$E2E_USER" "$E2E_PASS")
[ -x "$BIN_DIR/create_user$EXT" ] || cmd_build
(cd "$STATE" && "$BIN_DIR/create_user$EXT" "$E2E_USER" "$E2E_PASS")
}
login() {
curl.exe -sf -X POST "$URL/login" -H "Content-Type: application/json" \
$CURL -sf -X POST "$URL/login" -H "Content-Type: application/json" \
-d "{\"username\":\"$E2E_USER\",\"password\":\"$E2E_PASS\"}" 2>/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: curl.exe otherwise drains stdin, eating a caller's `while read` input.
curl.exe -sf -X "$1" "$URL/$2" -H "Authorization: Bearer $tok" \
$CURL -sf -X "$1" "$URL/$2" -H "Authorization: Bearer $tok" \
-H "Content-Type: application/json" ${3:+-d "$3"} </dev/null
}
@@ -192,7 +206,11 @@ cmd_start() {
cmd_stop() {
if is_running; then
log "stopping $EXE_NAME"
taskkill.exe /IM "$EXE_NAME" /F >/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 <<EOF
URL (Windows browser / curl.exe): $URL
URL (browser / curl): $URL
URL (Android emulator): http://10.0.2.2:$E2E_PORT
URL (USB device, after reverse): adb reverse tcp:$E2E_PORT tcp:$E2E_PORT -> $URL
Login: $E2E_USER / $E2E_PASS
Regular → Executable
+53 -7
View File
@@ -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 <out_dir> [--import DIR]
<out_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()