#!/usr/bin/env bash # A P1/A1 camera, end to end: the real proxy reads a JPEG frame from the real # printer's port 6000 (read-only - it only watches), the real backend relays # it, and an authenticated viewer fetches it. # # Needs a printer in ../continuum-proxy/printers.toml whose camera is the # P1/A1 kind (CAMERA_ID, default p1p) and that is switched on. Touches the # printer's camera and MQTT status, nothing else; no command is sent. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" CAMERA_ID="${CAMERA_ID:-p1p}" GO2RTC_BIN="${GO2RTC_BIN:-$HOME/.local/bin/go2rtc}" PROXY_DIR="${PROXY_DIR:-$REPO_ROOT/../continuum-proxy}" PORT="${PORT:-3050}" AUTH_PORT="${AUTH_PORT:-3051}" BASE="http://localhost:$PORT" DATABASE_URL="${DATABASE_URL:-postgres://continuum:continuum@localhost:5432/continuum}" VALKEY_URL="${VALKEY_URL:-redis://localhost:6379}" EDGE_JWT_SECRET="${EDGE_JWT_SECRET:-e2e-edge-secret}" R2_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID:-e2e}" R2_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY:-e2e}" R2_ENDPOINT="${R2_ENDPOINT:-http://localhost:9000}" export DATABASE_URL VALKEY_URL EDGE_JWT_SECRET PORT R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY R2_ENDPOINT # Where to keep the last frame for a human to look at. Unset: not kept. SAVE_FRAME="${SAVE_FRAME:-}" # BROWSER=1: also open the real web console in real Chrome (needs the real # Supabase test user, so source ../dev-secrets.sh). BROWSER="${BROWSER:-0}" APP_HOST="${APP_HOST:-127.0.0.1}" APP_DIR="${APP_DIR:-$REPO_ROOT/../continuum-app}" APP_PORT="${APP_PORT:-3052}" CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome}" APP_PID="" WORK="$(mktemp -d)" BACKEND_PID=""; PROXY_PID=""; AUTH_PID="" fail() { echo "FAIL: $*" >&2; exit 1; } ok() { echo " ok: $*"; } psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; } USER_ID="$(cat /proc/sys/kernel/random/uuid)" cleanup() { local rc=$? for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill "$pid" 2>/dev/null || true; done [ -n "$APP_PID" ] && kill -- "-$APP_PID" 2>/dev/null || true for _ in $(seq 1 20); do [ -n "$PROXY_PID" ] && kill -0 "$PROXY_PID" 2>/dev/null || break; sleep 0.3; done for pid in "$PROXY_PID" "$BACKEND_PID" "$AUTH_PID"; do [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null || true; done [ -n "$APP_PID" ] && kill -9 -- "-$APP_PID" 2>/dev/null || true [ -n "${ORG_ID:-}" ] && psql_q "DELETE FROM organisations WHERE id = '$ORG_ID'" >/dev/null 2>&1 || true if [ $rc -ne 0 ]; then echo; echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true echo "--- proxy log (tail) ---"; tail -30 "$WORK/proxy.log" 2>/dev/null || true fi rm -rf "$WORK" sleep 0.5 if ps -eo comm | grep -qx go2rtc; then echo "WARNING: a go2rtc process is still running after cleanup" >&2; [ $rc -eq 0 ] && rc=1 fi exit $rc } trap cleanup EXIT echo "==> checking preconditions" psql_q "SELECT camera_snapshot FROM printers LIMIT 0" >/dev/null 2>&1 \ || fail "database lacks printers.camera_snapshot - run scripts/dev-env.sh schema" [ -x "$GO2RTC_BIN" ] || fail "no go2rtc at $GO2RTC_BIN" [ -f "$PROXY_DIR/printers.toml" ] || fail "no $PROXY_DIR/printers.toml" grep -q "^id *= *\"$CAMERA_ID\"" "$PROXY_DIR/printers.toml" || fail "printers.toml has no printer '$CAMERA_ID'" ps -eo comm | grep -qx go2rtc && fail "a go2rtc is already running" CAM_HOST="$(awk -v id="$CAMERA_ID" -F'"' '/^id *=/ {cur=$2} cur==id && /^(host|address|ip) *=/ {print $2; exit}' "$PROXY_DIR/printers.toml")" if [ -n "$CAM_HOST" ]; then timeout 4 bash -c "/dev/null || fail "$CAMERA_ID ($CAM_HOST) refuses TCP 6000: no snapshot camera, or it is off" ok "$CAMERA_ID accepts connections on 6000" fi if [ "$BROWSER" = "1" ]; then for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do [ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh (BROWSER=1)" done [ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN" [ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app" SB_USER="$(bun scripts/e2e-supabase-user.ts)" || fail "could not provision the Supabase test user" sb_field() { printf '%s' "$SB_USER" | python3 -c 'import json,sys; print(json.load(sys.stdin)[sys.argv[1]])' "$1"; } USER_ID="$(sb_field id)"; USER_TOKEN="$(sb_field accessToken)"; SB_EMAIL="$(sb_field email)"; SB_PASSWORD="$(sb_field password)" unset SB_USER ok "signed in as the real Supabase test user" fi echo "==> seeding an organisation and a farm claiming '$CAMERA_ID'" EDGE_PROXY_TOKEN="e2e-$(date +%s)" ORG_ID="$(psql_q "INSERT INTO organisations (name) VALUES ('e2e snapshot org') RETURNING id")" psql_q "INSERT INTO org_members (org_id, user_id, role) VALUES ('$ORG_ID', '$USER_ID', 'viewer')" >/dev/null FARM_ID="$(psql_q "INSERT INTO farms (name, org_id, edge_proxy_token) VALUES ('e2e snapshot farm', '$ORG_ID', '$EDGE_PROXY_TOKEN') RETURNING id")" psql_q "INSERT INTO printers (farm_id, name, vendor, gateway_printer_id) VALUES ('$FARM_ID', 'snapshot under test', 'bambu', '$CAMERA_ID')" >/dev/null CAM_UUID="$(psql_q "SELECT id FROM printers WHERE farm_id='$FARM_ID'")" echo "==> starting the auth issuer and the backend" if [ "$BROWSER" != "1" ]; then bun scripts/dev-auth.ts --port "$AUTH_PORT" > "$WORK/auth.log" 2>&1 & AUTH_PID=$! AUTH_URL="http://localhost:$AUTH_PORT" for _ in $(seq 1 40); do curl -sf "$AUTH_URL/auth/v1/.well-known/jwks.json" >/dev/null 2>&1 && break; sleep 0.25; done export SUPABASE_URL="$AUTH_URL" USER_TOKEN="$(curl -sf "$AUTH_URL/token?sub=$USER_ID" | python3 -c 'import json,sys; print(json.load(sys.stdin)["access_token"])')" fi DISPATCH_ENABLED=false bun run src/index.ts > "$WORK/backend.log" 2>&1 & BACKEND_PID=$! for _ in $(seq 1 60); do curl -sf "$BASE/health" >/dev/null 2>&1 && break; sleep 0.5; done curl -sf "$BASE/health" >/dev/null || fail "backend did not become healthy" echo "==> starting the real continuum-proxy" ( cd "$PROXY_DIR" && cargo build --quiet 2>&1 | tail -3 ) || fail "proxy did not build" env -C "$PROXY_DIR" \ CONTINUUM_GATEWAY_ID=e2e-snap-gw CONTINUUM_FARM_ID="$FARM_ID" CONTINUUM_GATEWAY_TOKEN="$EDGE_PROXY_TOKEN" \ CONTINUUM_UPLINK_URL="ws://localhost:$PORT/ws/edge/v1" \ CONTINUUM_GO2RTC_BIN="$GO2RTC_BIN" CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_PRINTERS_PATH=printers.toml CONTINUUM_STORE_PATH="$WORK/continuum.db" RUST_LOG=info \ "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & PROXY_PID=$! echo "==> waiting for the camera to be advertised as a snapshot camera" STREAM="" for _ in $(seq 1 45); do STREAM="$(curl -sf -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/stream" 2>/dev/null || true)" [ -n "$STREAM" ] && break sleep 1 done [ -n "$STREAM" ] || fail "GET /printers/$CAM_UUID/stream never answered 200" printf '%s' "$STREAM" | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["kind"]=="snapshot" and "iceServers" not in d, d' \ || fail "the stream answer is not a snapshot answer" ok "the stream endpoint says kind=snapshot (no WebRTC session offered)" echo "==> fetching frames" check_frame() { local file="$1" label="$2" size size="$(stat -c %s "$file")" [ "$size" -gt 1000 ] || fail "$label: only $size bytes" [ "$(head -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd8" ] || fail "$label: does not start with a JPEG marker" [ "$(tail -c2 "$file" | od -An -tx1 | tr -d ' \n')" = "ffd9" ] || fail "$label: does not end with a JPEG marker" ok "$label: a $size-byte JPEG" } fetch() { curl -s -o "$1" -D "$1.h" -w '%{http_code}' -H "authorization: Bearer $USER_TOKEN" "$BASE/printers/$CAM_UUID/snapshot"; } code="$(fetch "$WORK/f1.jpg")" [ "$code" = 200 ] || fail "first frame: HTTP $code: $(head -c 300 "$WORK/f1.jpg")" grep -qi '^content-type: image/jpeg' "$WORK/f1.jpg.h" || fail "wrong content-type" grep -qi '^cache-control: no-store' "$WORK/f1.jpg.h" || fail "frames must be no-store" check_frame "$WORK/f1.jpg" "first frame" sleep 1.2 code="$(fetch "$WORK/f2.jpg")"; [ "$code" = 200 ] || fail "second frame: HTTP $code" check_frame "$WORK/f2.jpg" "second frame (a fresh read after the cache window)" cmp -s "$WORK/f1.jpg" "$WORK/f2.jpg" && fail "two frames a second apart are byte-identical - is the cache serving stale pictures?" ok "consecutive frames differ" echo "==> five viewers at once share the camera" viewers=() for i in 1 2 3 4 5; do fetch "$WORK/c$i.jpg" > "$WORK/c$i.code" & viewers+=($!); done # Not a bare `wait`: that would also wait for the backend and the proxy. for pid in "${viewers[@]}"; do wait "$pid"; done for i in 1 2 3 4 5; do [ "$(cat "$WORK/c$i.code")" = 200 ] || fail "viewer $i: HTTP $(cat "$WORK/c$i.code")"; check_frame "$WORK/c$i.jpg" "viewer $i"; done ok "all five got a picture" echo "==> access control" [ "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/printers/$CAM_UUID/snapshot")" = 401 ] || fail "no token must be 401" ok "no token: 401" # The code is never read out of printers.toml here; the camera login happens # on the gateway, and the only thing that should cross is the picture. if grep -qi 'access_code\|accessCode' "$WORK/backend.log"; then fail "the backend log mentions an access code" fi ok "the backend log never mentions an access code" # Optional soak: the first real dispatch froze the proxy ~4.5 min in (an # MQTT publish that never completed while the fleet lock was held). Staying # connected well past that and still receiving telemetry is the regression test. if [ -n "${SOAK_S:-}" ]; then echo "==> soaking for ${SOAK_S}s: telemetry must keep arriving" sleep "$SOAK_S" AGE="$(psql_q "SELECT extract(epoch FROM now() - last_seen_at)::int FROM printers WHERE id='$CAM_UUID'")" [ -n "$AGE" ] && [ "$AGE" -lt 20 ] || fail "telemetry is ${AGE:-never}s old after the soak - the proxy froze" ok "telemetry still fresh (${AGE}s old) after ${SOAK_S}s" code="$(fetch "$WORK/after.jpg")"; [ "$code" = 200 ] || fail "no camera frame after the soak: HTTP $code" ok "camera still answers after the soak" fi if [ "$BROWSER" = "1" ]; then echo "==> the real console in real Chrome" setsid env -C "$APP_DIR/apps/web" NUXT_PUBLIC_API_BASE="$BASE" NUXT_PUBLIC_WS_BASE="ws://localhost:$PORT" \ SUPABASE_URL="$SUPABASE_URL" SUPABASE_KEY="$SUPABASE_PUBLISHABLE_KEY" NUXT_PUBLIC_PASSWORD_LOGIN=true \ pnpm exec nuxt dev --host "$APP_HOST" --port "$APP_PORT" > "$WORK/app.log" 2>&1 & APP_PID=$! for _ in $(seq 1 120); do curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" 2>/dev/null && break; sleep 1; done curl -sf -o /dev/null "http://127.0.0.1:$APP_PORT/login" || { tail -20 "$WORK/app.log" >&2; fail "the web console did not start"; } OUT="$(env APP_URL="http://127.0.0.1:$APP_PORT" SUPABASE_E2E_EMAIL="$SB_EMAIL" SUPABASE_E2E_PASSWORD="$SB_PASSWORD" \ CHROME_BIN="$CHROME_BIN" timeout 240 bun scripts/e2e-camera-snapshot-browser.ts 2>&1 || true)" seen=0; bad=0 while IFS=$'\t' read -r verdict label; do case "$verdict" in ok) ok "$label"; seen=$((seen + 1)) ;; FAILED) echo " FAILED: $label" >&2; seen=$((seen + 1)); bad=$((bad + 1)) ;; esac done < <(printf '%s\n' "$OUT" | grep -E "^(ok|FAILED)$(printf '\t')") if [ "$seen" -lt 8 ] && [ "$bad" -eq 0 ]; then bad=1; echo " FAILED: only $seen of 8 browser results" >&2; fi if [ "$bad" -ne 0 ]; then printf '%s\n' "$OUT" | sed 's/^/ | /' >&2; tail -15 "$WORK/app.log" >&2 fail "the real browser did not show the snapshot camera" fi fi [ -n "$SAVE_FRAME" ] && cp "$WORK/f2.jpg" "$SAVE_FRAME" && ok "kept a frame at $SAVE_FRAME" echo echo "PASS - a P1/A1 camera frame travelled printer -> proxy -> backend -> an authenticated viewer."