Files
ImageApi/dev/e2e/e2e.sh
T
Cameron CordesandClaude Opus 5.5 6135034d09 feat(versions): e2e fixtures, check_versions, docs
Fixtures gain an XMP-linked version (renamed file), a name-linked version
and an edit with a reference photo; e2e.sh counts with include_versions and
runs check_versions.py. CLAUDE.md documents the feature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 13:53:21 -04:00

284 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Disposable ImageApi instance for end-to-end / agent-driven UI testing.
#
# 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, 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.
#
# 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
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
WORK="$HERE/.work"
FIXTURES="$WORK/fixtures"
STATE="$WORK/state"
BIN_DIR="$WORK/bin"
# 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}"
# 127.0.0.1 is reachable from the Windows browser (Expo web), the Android
# emulator (as 10.0.2.2) and USB devices via `adb reverse`. Set
# E2E_BIND=0.0.0.0 for a physical device over Wi-Fi.
E2E_BIND="${E2E_BIND:-127.0.0.1}"
E2E_USER="${E2E_USER:-e2e}"
E2E_PASS="${E2E_PASS:-E2eTest#2026pass}"
URL="http://127.0.0.1:$E2E_PORT"
# 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() {
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 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$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
}
cmd_fixtures() {
local force=0 import=()
while [ $# -gt 0 ]; do
case "$1" in
--force) force=1 ;;
--import) import=(--import "$2"); force=1; shift ;;
*) die "unknown fixtures option: $1" ;;
esac
shift
done
if [ -f "$FIXTURES/manifest.json" ] && [ "$force" = 0 ]; then
log "fixtures exist (generated $(grep -o '"generated_on": "[^"]*"' "$FIXTURES/manifest.json" | cut -d'"' -f4)); --force to regenerate"
return
fi
log "generating fixtures"
python3 "$HERE/gen_fixtures.py" "$FIXTURES" "${import[@]}"
}
write_env() {
cat > "$STATE/.env" <<EOF
# Generated by dev/e2e/e2e.sh — edit dev/e2e/env.local instead (appended below).
DATABASE_URL='$(win_join "$STATE" e2e.db)'
BASE_PATH='$(win "$STATE/media")'
THUMBNAILS='$(win "$STATE/thumbs")'
VIDEO_PATH='$(win "$STATE/hls")'
GIFS_DIRECTORY='$(win "$STATE/gifs")'
PREVIEW_CLIPS_DIRECTORY='$(win "$STATE/previews")'
BIND_URL=$E2E_BIND:$E2E_PORT
# Don't claim localhost:8088 — the real server owns it.
LOCAL_BIND_URL=
# Expo web (Metro) origins.
CORS_ALLOWED_ORIGINS=http://localhost:8081,http://localhost:19006
SECRET_KEY=e2e-dev-only-secret-not-for-production
RUST_LOG=info
# Pin the server zone (ImageApi #120 preflight): the Windows exe has no
# /etc/timezone, so without this server_zone() is None and assumed_server
# rows fall back to the process-local offset.
TZ=America/New_York
# Fast ingest so a fresh reset is browsable within seconds.
WATCH_QUICK_INTERVAL_SECONDS=3
WATCH_FULL_INTERVAL_SECONDS=60
EXCLUDED_DIRS=/private
HLS_CONCURRENCY=2
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
# insight calls fail fast instead of hitting the real local models. Opt in
# via env.local (LLM_BACKEND, LLAMA_SWAP_URL, APOLLO_API_BASE_URL, ...).
OLLAMA_PRIMARY_URL=http://127.0.0.1:9
EOF
if [ -f "$HERE/env.local" ]; then
{ echo; echo "# ── dev/e2e/env.local ──"; cat "$HERE/env.local"; } >> "$STATE/.env"
fi
}
cmd_reset() {
[ -f "$FIXTURES/manifest.json" ] || cmd_fixtures
is_running && cmd_stop
log "resetting state (db, thumbs, hls, media copy)"
rm -rf "$STATE"
mkdir -p "$STATE"/{thumbs,hls,gifs,previews}
cp -a "$FIXTURES/media" "$STATE/media"
cp "$FIXTURES/manifest.json" "$STATE/manifest.json"
write_env
[ -x "$BIN_DIR/create_user$EXT" ] || cmd_build
(cd "$STATE" && "$BIN_DIR/create_user$EXT" "$E2E_USER" "$E2E_PASS")
}
login() {
$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
}
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 -sf -X "$1" "$URL/$2" -H "Authorization: Bearer $tok" \
-H "Content-Type: application/json" ${3:+-d "$3"} </dev/null
}
indexed_count() {
api GET "photos?path=&recursive=true&include_versions=true" | python3 -c \
'import json,sys; print(len(json.load(sys.stdin).get("photos", [])))' 2>/dev/null || echo 0
}
seed_tags() {
local E2E_TOKEN; E2E_TOKEN="$(login)"
python3 - "$STATE/manifest.json" <<'EOF' | while IFS=$'\t' read -r path tag; do
import json, sys
for tag, paths in json.load(open(sys.argv[1]))["tags"].items():
for p in paths:
print(f"{p}\t{tag}")
EOF
api POST "image/tags" "{\"file_name\":\"$path\",\"tag_name\":\"$tag\"}" >/dev/null \
|| log "warn: failed to tag $path"
done
touch "$STATE/.tags_seeded"
}
cmd_start() {
is_running && { log "already running at $URL"; return; }
[ -f "$STATE/.env" ] || cmd_reset
[ -x "$BIN_DIR/$EXE_NAME" ] || cmd_build
log "starting $EXE_NAME on $E2E_BIND:$E2E_PORT"
# cwd = state dir: the server reads .env from its working directory.
# Redirect the whole subshell, not just the exe: an inherited stdout
# would keep a caller's pipe (`e2e.sh up | tail`) open until shutdown.
(cd "$STATE" && exec setsid nohup "$BIN_DIR/$EXE_NAME") </dev/null >>"$LOG" 2>&1 &
disown
for _ in $(seq 1 60); do
[ -n "$(login)" ] && break
is_running || die "server exited; see $LOG"
sleep 1
done
[ -n "$(login)" ] || die "server did not accept logins within 60s; see $LOG"
[ -f "$STATE/.tags_seeded" ] || { log "seeding tags"; seed_tags; }
local want have=0
want="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["counts"]["indexed"])' "$STATE/manifest.json")"
log "waiting for ingest ($want indexable fixtures)"
for _ in $(seq 1 90); do
have="$(indexed_count)"
[ "$have" -ge "$want" ] && break
sleep 2
done
if [ "$have" -ge "$want" ]; then
log "ready: $have files indexed"
else
log "warn: only $have/$want indexed after 180s (still ingesting?); see $LOG"
fi
cmd_info
}
cmd_stop() {
if is_running; then
log "stopping $EXE_NAME"
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"
fi
}
cmd_status() {
if is_running; then
echo "running $URL (indexed: $(indexed_count))"
else
echo "stopped"
fi
}
cmd_info() {
cat <<EOF
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
Manifest (expected content): $STATE/manifest.json
Log: $LOG
EOF
}
cmd_help() {
cat <<EOF
Usage: dev/e2e/e2e.sh <command>
up build (if needed) + fixtures (if needed) + reset + start
build cargo build into target/e2e and install the e2e exe copies
fixtures [--force] [--import DIR]
generate the pristine fixture library (--import copies a
real folder in as media/imported/; the source is read-only)
reset stop, wipe db/thumbs/hls, re-copy media, recreate user
start | stop | restart | status
check verify every fixture's displayed capture time (wall_clock)
on metadata, memories, gps-summary and photos/exif against
the manifest, plus photos/exif date-range edges (#152)
token print a fresh JWT for the e2e user
api METHOD PATH [JSON]
authenticated request, e.g. api GET 'photos?path=&recursive=true'
logs tail -f the server log
info connection details
Env: E2E_PORT (8090), E2E_BIND (127.0.0.1), E2E_USER, E2E_PASS.
Extra server env: dev/e2e/env.local (appended to the generated .env on reset).
EOF
}
case "${1:-help}" in
up) shift; [ -x "$BIN_DIR/$EXE_NAME" ] || cmd_build; cmd_fixtures; cmd_reset; cmd_start ;;
build) cmd_build ;;
fixtures) shift; cmd_fixtures "$@" ;;
reset) cmd_reset ;;
start) cmd_start ;;
stop) cmd_stop ;;
restart) cmd_stop; cmd_start ;;
status) cmd_status ;;
token) login ;;
check) E2E_TOKEN="$(login)" python3 "$HERE/check_wall_clock.py" "$STATE/manifest.json" \
&& E2E_TOKEN="$(login)" python3 "$HERE/check_versions.py" "$STATE/manifest.json" ;;
api) shift; api "$@" ;;
logs) tail -f "$LOG" ;;
info) cmd_info ;;
help|-h|--help) cmd_help ;;
*) die "unknown command: $1 (try help)" ;;
esac