Merge pull request 'chore(e2e): run the e2e dev server on native Linux' (#172) from feat/e2e-native-linux into master

Reviewed-on: #172
This commit was merged in pull request #172.
This commit is contained in:
cameron committed 2026-10-16 19:26:41 +00:00
commit 42897990d8
4 files changed
+95 -26

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`), emulator) or `http://127.0.0.1:8090` (Expo web / `adb reverse tcp:8090 tcp:8090`),
**user** `e2e`, **password** `E2eTest#2026pass`. **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 ## Isolation
It runs next to a real local server without touching it: It runs next to a real local server without touching it:
Regular → Executable
View File
File mode changed.
Regular → Executable
+37 -19
View File
@@ -3,14 +3,14 @@
# #
# Runs a second, fully isolated server next to any real one: its own build # 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), # 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 # its own process name (image-api-e2e, so `stop` can never touch the real
# real server), its own port, database, thumbnails and HLS output. The # server), its own port, database, thumbnails and HLS output. The media
# media library is a *copy* of the pristine fixtures, so tests may upload, # library is a *copy* of the pristine fixtures, so tests may upload, move
# move and delete freely; `reset` restores the known baseline. # and delete freely; `reset` restores the known baseline.
# #
# Written for WSL driving the Windows toolchain (cargo.exe, curl.exe, # Works on native Linux, and in WSL driving the Windows toolchain
# ffmpeg.exe). WSL2's default NAT networking can't reach Windows' # (cargo.exe, curl.exe, ffmpeg.exe). WSL2's default NAT networking can't
# 127.0.0.1, hence curl.exe for every HTTP call. # reach Windows' 127.0.0.1, hence curl.exe for every HTTP call there.
# #
# Usage: dev/e2e/e2e.sh <command> (see `help`) # Usage: dev/e2e/e2e.sh <command> (see `help`)
set -euo pipefail set -euo pipefail
@@ -21,7 +21,14 @@ WORK="$HERE/.work"
FIXTURES="$WORK/fixtures" FIXTURES="$WORK/fixtures"
STATE="$WORK/state" STATE="$WORK/state"
BIN_DIR="$WORK/bin" 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" LOG="$WORK/server.log"
E2E_PORT="${E2E_PORT:-8090}" E2E_PORT="${E2E_PORT:-8090}"
@@ -33,23 +40,30 @@ E2E_USER="${E2E_USER:-e2e}"
E2E_PASS="${E2E_PASS:-E2eTest#2026pass}" E2E_PASS="${E2E_PASS:-E2eTest#2026pass}"
URL="http://127.0.0.1:$E2E_PORT" 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; } log() { printf '\033[1;34m[e2e]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[e2e]\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31m[e2e]\033[0m %s\n' "$*" >&2; exit 1; }
is_running() { is_running() {
if [ "$WSL" = 1 ]; then
tasklist.exe /FI "IMAGENAME eq $EXE_NAME" /NH 2>/dev/null | grep -qi "$EXE_NAME" 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() { cmd_build() {
log "building bins into target/e2e" 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" mkdir -p "$BIN_DIR"
# Run from a copy so rebuilding never fights a running (locked) exe. # Run from a copy so rebuilding never fights a running (locked) exe.
local was_running=0 local was_running=0
if is_running; then was_running=1; cmd_stop; fi 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/image-api$EXT" "$BIN_DIR/$EXE_NAME"
cp "$REPO/target/e2e/debug/create_user.exe" "$BIN_DIR/create_user.exe" cp "$REPO/target/e2e/debug/create_user$EXT" "$BIN_DIR/create_user$EXT"
if [ "$was_running" = 1 ]; then cmd_start; fi if [ "$was_running" = 1 ]; then cmd_start; fi
} }
@@ -74,7 +88,7 @@ cmd_fixtures() {
write_env() { write_env() {
cat > "$STATE/.env" <<EOF cat > "$STATE/.env" <<EOF
# Generated by dev/e2e/e2e.sh — edit dev/e2e/env.local instead (appended below). # 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")' BASE_PATH='$(win "$STATE/media")'
THUMBNAILS='$(win "$STATE/thumbs")' THUMBNAILS='$(win "$STATE/thumbs")'
VIDEO_PATH='$(win "$STATE/hls")' VIDEO_PATH='$(win "$STATE/hls")'
@@ -96,7 +110,7 @@ WATCH_QUICK_INTERVAL_SECONDS=3
WATCH_FULL_INTERVAL_SECONDS=60 WATCH_FULL_INTERVAL_SECONDS=60
EXCLUDED_DIRS=/private EXCLUDED_DIRS=/private
HLS_CONCURRENCY=2 HLS_CONCURRENCY=2
USER_NAME=E2E Tester USER_NAME='E2E Tester'
REEL_PREGEN_ENABLED=0 REEL_PREGEN_ENABLED=0
REEL_CACHE_SWEEP_ENABLED=0 REEL_CACHE_SWEEP_ENABLED=0
# AI / sibling services are off by default: point Ollama at a closed port so # 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 -a "$FIXTURES/media" "$STATE/media"
cp "$FIXTURES/manifest.json" "$STATE/manifest.json" cp "$FIXTURES/manifest.json" "$STATE/manifest.json"
write_env write_env
[ -x "$BIN_DIR/create_user.exe" ] || cmd_build [ -x "$BIN_DIR/create_user$EXT" ] || cmd_build
(cd "$STATE" && "$BIN_DIR/create_user.exe" "$E2E_USER" "$E2E_PASS") (cd "$STATE" && "$BIN_DIR/create_user$EXT" "$E2E_USER" "$E2E_PASS")
} }
login() { 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 \ -d "{\"username\":\"$E2E_USER\",\"password\":\"$E2E_PASS\"}" 2>/dev/null \
| grep -o '"token":"[^"]*"' | cut -d'"' -f4 | 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) api() { # api METHOD PATH [JSON]; reuses $E2E_TOKEN when set (login is a bcrypt verify)
local tok="${E2E_TOKEN:-$(login)}" local tok="${E2E_TOKEN:-$(login)}"
# </dev/null: curl.exe otherwise drains stdin, eating a caller's `while read` input. # </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 -H "Content-Type: application/json" ${3:+-d "$3"} </dev/null
} }
@@ -192,7 +206,11 @@ cmd_start() {
cmd_stop() { cmd_stop() {
if is_running; then if is_running; then
log "stopping $EXE_NAME" log "stopping $EXE_NAME"
if [ "$WSL" = 1 ]; then
taskkill.exe /IM "$EXE_NAME" /F >/dev/null 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 for _ in $(seq 1 20); do is_running || break; sleep 0.5; done
else else
log "not running" log "not running"
@@ -210,7 +228,7 @@ cmd_status() {
cmd_info() { cmd_info() {
cat <<EOF cat <<EOF
URL (Windows browser / curl.exe): $URL URL (browser / curl): $URL
URL (Android emulator): http://10.0.2.2:$E2E_PORT URL (Android emulator): http://10.0.2.2:$E2E_PORT
URL (USB device, after reverse): adb reverse tcp:$E2E_PORT tcp:$E2E_PORT -> $URL URL (USB device, after reverse): adb reverse tcp:$E2E_PORT tcp:$E2E_PORT -> $URL
Login: $E2E_USER / $E2E_PASS 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 what was generated and what the server should derive from it, so tests
can assert against it instead of hardcoding counts. can assert against it instead of hardcoding counts.
The harness runs in WSL but the tools (ffmpeg.exe / exiftool.exe) are Runs on native Linux with ffmpeg / exiftool, or in WSL driving the Windows
Windows binaries, so every path handed to them goes through `wslpath -w`. 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] Usage: gen_fixtures.py <out_dir> [--import DIR]
<out_dir>/media/ the pristine library (never served directly) <out_dir>/media/ the pristine library (never served directly)
@@ -30,11 +31,48 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo 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") 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. def _font() -> str:
FONT = "C\\:/Windows/Fonts/consola.ttf" """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=...). # The owner's display zone and the server zone the e2e env pins (TZ=...).
# Naive fixture datetimes without GPS are walls in this zone. # Naive fixture datetimes without GPS are walls in this zone.
@@ -85,6 +123,10 @@ class Item:
def win(p: Path) -> str: 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() 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/") help="also copy this folder (read-only source) into media/imported/")
a = ap.parse_args() a = ap.parse_args()
if not FFMPEG or not EXIFTOOL: 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 return 1
out: Path = a.out_dir.resolve() out: Path = a.out_dir.resolve()