diff --git a/scripts/e2e-camera.sh b/scripts/e2e-camera.sh index 229e1d6..84ce759 100755 --- a/scripts/e2e-camera.sh +++ b/scripts/e2e-camera.sh @@ -274,6 +274,7 @@ env -C "$PROXY_DIR" \ CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \ 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=$! diff --git a/scripts/e2e-edge.sh b/scripts/e2e-edge.sh index 192a048..af10f28 100755 --- a/scripts/e2e-edge.sh +++ b/scripts/e2e-edge.sh @@ -220,6 +220,7 @@ env -C "$PROXY_DIR" \ CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \ 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=$! diff --git a/scripts/e2e-setup-browser.sh b/scripts/e2e-setup-browser.sh new file mode 100755 index 0000000..e472732 --- /dev/null +++ b/scripts/e2e-setup-browser.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# The first-run experience in the real web console, in a real Chrome: a user +# with no organisation signs in, creates a farm, copies the gateway settings, +# connects a gateway configured from exactly those settings, adds a printer +# through the form, gets an alert when it fails, and removes it. +# +# The gateway is a stand-in (scripts/fake-gateway-serve.ts), so NO printer and +# no network device is touched; e2e-provision-proxy.sh covers the real proxy. +# +# SAFETY: nothing here talks to a real printer, and dispatch is off. +# +# Needs: source ../dev-secrets.sh first (real Supabase, as e2e-camera.sh's +# BROWSER mode), a migrated database, Chrome, ../continuum-app installed, and +# port 3042 free. Uses the throwaway Supabase user continuum-e2e@example.invalid, +# which must have no organisation (it refuses to start otherwise, so it cannot +# trample a SERVE=1 stack) and removes what it creates. +# +# Usage: +# set -a; . ../dev-secrets.sh; set +a +# scripts/e2e-setup-browser.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +PORT="${PORT:-3036}" +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 + +APP_DIR="${APP_DIR:-$REPO_ROOT/../continuum-app}" +APP_PORT="${APP_PORT:-3042}" +CHROME_BIN="${CHROME_BIN:-/usr/bin/google-chrome}" + +WORK="$(mktemp -d)" +BACKEND_PID="" +APP_PID="" +USER_ID="" + +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo " ok: $*"; } +psql_q() { psql "$DATABASE_URL" -tAq -c "$1"; } + +cleanup() { + local rc=$? + [ -n "$BACKEND_PID" ] && kill "$BACKEND_PID" 2>/dev/null || true + [ -n "$APP_PID" ] && kill -- "-$APP_PID" 2>/dev/null || true + sleep 0.5 + [ -n "$BACKEND_PID" ] && kill -9 "$BACKEND_PID" 2>/dev/null || true + [ -n "$APP_PID" ] && kill -9 -- "-$APP_PID" 2>/dev/null || true + # What this run created, and only that: the user began with no organisation. + if [ -n "$USER_ID" ] && [ -z "${KEEP_DATA:-}" ]; then + psql_q "DELETE FROM organisations WHERE id IN (SELECT org_id FROM org_members WHERE user_id='$USER_ID')" >/dev/null 2>&1 || true + fi + if [ $rc -ne 0 ]; then + echo; echo "--- backend log (tail) ---"; tail -20 "$WORK/backend.log" 2>/dev/null || true + echo "--- web console log (tail) ---"; tail -15 "$WORK/app.log" 2>/dev/null || true + fi + rm -rf "$WORK" + exit $rc +} +trap cleanup EXIT + +for name in SUPABASE_URL SUPABASE_SECRET_KEY SUPABASE_PUBLISHABLE_KEY; do + [ -n "${!name:-}" ] || fail "$name is not set - source ../dev-secrets.sh" +done +psql_q "SELECT 1 FROM printer_alerts LIMIT 0" >/dev/null 2>&1 || fail "no migrated database - run scripts/dev-env.sh up && schema" +[ -x "$CHROME_BIN" ] || fail "no Chrome at $CHROME_BIN" +[ -d "$APP_DIR/apps/web/node_modules" ] || fail "$APP_DIR has no installed web app" +# bash's own /dev/tcp rather than `ss`, which is not installed everywhere. +if (exec 3<>"/dev/tcp/127.0.0.1/$APP_PORT") 2>/dev/null; then fail "port $APP_PORT is already in use (a SERVE=1 stack running?)"; fi + +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)"; SB_EMAIL="$(sb_field email)"; SB_PASSWORD="$(sb_field password)" +unset SB_USER +[ "$(psql_q "SELECT count(*) FROM org_members WHERE user_id='$USER_ID'")" = "0" ] \ + || fail "the test user already belongs to an organisation - stop any SERVE=1 stack first" +ok "a Supabase user with no organisation" + +echo "==> starting the backend on :$PORT" +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" +ok "backend healthy" + +echo "==> starting the web console on :$APP_PORT" +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 127.0.0.1 --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" || fail "the web console did not start" +ok "the web console is up" + +ACCESS_CODE="BROWSERCODE$(cat /proc/sys/kernel/random/uuid | cut -c1-6)" +FRAMES="$WORK/frames.jsonl"; : > "$FRAMES" + +echo "==> first run, in real Chrome" +OUT="$(env APP_URL="http://127.0.0.1:$APP_PORT" API_BASE="$BASE" WS_BASE="ws://localhost:$PORT" \ + SUPABASE_E2E_EMAIL="$SB_EMAIL" SUPABASE_E2E_PASSWORD="$SB_PASSWORD" CHROME_BIN="$CHROME_BIN" \ + FRAMES_FILE="$FRAMES" ACCESS_CODE="$ACCESS_CODE" \ + timeout 280 bun scripts/e2e-setup-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 18 ] && [ "$bad" -eq 0 ]; then + bad=1; echo " FAILED: the browser run produced only $seen of 18 results" >&2 +fi +if [ "$bad" -ne 0 ]; then + printf '%s\n' "$OUT" | sed 's/^/ | /' >&2 + fail "the first-run experience did not work" +fi + +# The credential the browser typed must rest nowhere in the cloud. +grep -q "$ACCESS_CODE" "$WORK/backend.log" && fail "the access code is in the backend log" +LEAK="$(psql "$DATABASE_URL" -tAq -c "SELECT string_agg(table_name, ' ') FROM information_schema.tables WHERE table_schema='public'" \ + | tr ' ' '\n' | while read -r tbl; do [ -n "$tbl" ] && psql_q "SELECT count(*) FROM \"$tbl\" t WHERE row_to_json(t)::text LIKE '%$ACCESS_CODE%'" | sed "s/^/$tbl /"; done | awk '$2>0')" +[ -z "$LEAK" ] || fail "the access code is stored in: $LEAK" +ok "the access code is in no table and no log" + +echo +echo "PASS - a new user went from nothing to a farm, a gateway, a printer, an alert and back." diff --git a/scripts/e2e-setup-browser.ts b/scripts/e2e-setup-browser.ts new file mode 100644 index 0000000..8327ef6 --- /dev/null +++ b/scripts/e2e-setup-browser.ts @@ -0,0 +1,158 @@ +/** + * Drives the console's first-run experience in a real Chrome, for + * scripts/e2e-setup-browser.sh: a brand-new user with nothing signs in, creates + * a farm, gets gateway settings, connects a (stand-in) gateway with them, adds + * a printer through the form, sees an alert when it fails, dismisses it, and + * removes the printer. + * + * Results are `oklabel` / `FAILEDlabel`. The password, the gateway + * token and the printer's access code never reach a log line. + * + * Environment: APP_URL, API_BASE, WS_BASE, SUPABASE_E2E_EMAIL, + * SUPABASE_E2E_PASSWORD, FRAMES_FILE, ACCESS_CODE, CHROME_BIN. + */ +import { readFileSync } from "node:fs"; +import puppeteer from "puppeteer-core"; + +const { APP_URL, API_BASE, WS_BASE, FRAMES_FILE, ACCESS_CODE } = process.env as Record; +const email = process.env.SUPABASE_E2E_EMAIL!; +const password = process.env.SUPABASE_E2E_PASSWORD!; +const chromeBin = process.env.CHROME_BIN ?? "/usr/bin/google-chrome"; + +let failed = false; +const say = (good: boolean, label: string, detail = "") => { + if (!good) failed = true; + console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`); +}; + +const browser = await puppeteer.launch({ + executablePath: chromeBin, + headless: true, + args: ["--no-sandbox", "--disable-dev-shm-usage"], +}); + +let gateway: ReturnType | undefined; + +try { + const page = await browser.newPage(); + const pageErrors: string[] = []; + page.on("pageerror", (err) => pageErrors.push(String((err as Error).message ?? err).slice(0, 160))); + + const waitText = (selector: string, text: string, timeout = 30_000) => + page + .waitForFunction( + (sel: string, want: string) => document.querySelector(sel)?.textContent?.includes(want) ?? false, + { timeout }, + selector, + text, + ) + .then(() => true) + .catch(() => false); + + await page.goto(`${APP_URL}/login`, { waitUntil: "domcontentloaded", timeout: 120_000 }); + await page.waitForFunction( + () => typeof (window as any).useNuxtApp === "function" && Boolean((window as any).useNuxtApp().$supabase), + { timeout: 120_000 }, + ); + const signInError = await page.evaluate( + async (e: string, p: string) => { + const { client } = (window as any).useNuxtApp().$supabase; + const { error } = await client.auth.signInWithPassword({ email: e, password: p }); + return error ? String(error.message) : null; + }, + email, + password, + ); + say(signInError === null, "a brand-new user signs in", signInError ?? ""); + if (signInError) process.exit(1); + + // 1. nothing yet -> pointed at Setup + await page.goto(`${APP_URL}/`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + say(await waitText(".fleet", "You have no farm yet"), "the empty fleet page points a new user to Setup"); + + // 2. onboarding + await page.goto(`${APP_URL}/setup`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForSelector(".onboarding input", { timeout: 30_000 }); + await page.type(".onboarding input", "Browser Farm"); + await page.click(".onboarding button[type=submit]"); + say(await waitText(".setup__farm h2", "Browser Farm"), "creating a farm from nothing works (org and farm)"); + + // 3. gateway settings + await page.waitForSelector(".gateway-setup button", { timeout: 15_000 }); + say( + await page.$eval(".gateway-setup", (el) => el.textContent?.includes("No gateway is connected") ?? false), + "setup says no gateway is connected yet", + ); + await page.click(".gateway-setup button"); + await page.waitForSelector(".gateway-setup__settings", { timeout: 15_000 }); + const settings = await page.$eval(".gateway-setup__settings", (el) => el.textContent ?? ""); + const farmId = /farm_id = "([^"]+)"/.exec(settings)?.[1] ?? ""; + const proxyToken = /gateway_token = "([^"]+)"/.exec(settings)?.[1] ?? ""; + const uplink = /uplink_url = "([^"]+)"/.exec(settings)?.[1] ?? ""; + say(Boolean(farmId && proxyToken && uplink.endsWith("/ws/edge/v1")), "the page shows complete gateway settings"); + + // 4. a gateway configured from exactly what the page showed + const exchanged = await fetch(`${API_BASE}/auth/edge-token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ farmId, edgeProxyToken: proxyToken }), + }); + say(exchanged.ok, "the token the page showed is accepted by the cloud"); + const jwt = ((await exchanged.json()) as { token: string }).token; + gateway = Bun.spawn(["bun", "scripts/fake-gateway-serve.ts", `${WS_BASE}/ws/edge/v1`, jwt, "120", FRAMES_FILE], { + env: { ...process.env, FARM_ID: farmId, TELEMETRY_AFTER_PROVISION: "1" }, + stdout: "ignore", + stderr: "ignore", + }); + say(await waitText(".gateway-setup", "A gateway is connected", 30_000), "the page notices the gateway connecting"); + await page.click(".gateway-setup button:last-of-type"); // Done: the token leaves the screen + say( + !(await page.$eval("body", (el, token) => el.textContent?.includes(token) ?? false, proxyToken)), + "dismissing the settings removes the token from the page", + ); + + // 5. add a printer + const fields = await page.$$(".add-printer form input"); + await fields[0]!.type("Bench P1P"); + await fields[1]!.type("192.168.0.77"); + await fields[2]!.type("01P00A000000007"); + await fields[3]!.type(ACCESS_CODE); + await page.click(".add-printer button[type=submit]"); + say(await waitText(".add-printer", "Added Bench P1P", 45_000), "adding a printer through the form succeeds"); + say(await waitText(".managed", "Bench P1P"), "the printer appears in the list"); + const leftover = await page.$eval(".add-printer input[type=password]", (el) => (el as HTMLInputElement).value); + say(leftover === "", "the access code is cleared from the form after sending"); + const frames = readFileSync(FRAMES_FILE, "utf8"); + say(frames.includes(ACCESS_CODE), "the gateway received the access code"); + + // 6. the printer fails -> an alert on the fleet page + await page.goto(`${APP_URL}/`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + say(await waitText(".alert-list", "the print failed", 60_000), "a failed print raises an alert on the fleet page"); + await page.click(".alert-list button"); + const gone = await page + .waitForFunction(() => document.querySelector(".alert-list") === null, { timeout: 20_000 }) + .then(() => true) + .catch(() => false); + say(gone, "dismissing the alert removes it"); + + // 7. remove + await page.goto(`${APP_URL}/setup`, { waitUntil: "domcontentloaded", timeout: 60_000 }); + await page.waitForSelector(".managed button", { timeout: 20_000 }); + await page.click(".managed button"); + say(await waitText(".managed", "forget its access code"), "removing asks first, and says what it deletes"); + const buttons = await page.$$(".managed button"); + await buttons[0]!.click(); // "Yes, remove" + const removed = await page + .waitForFunction(() => document.querySelector(".managed") === null, { timeout: 30_000 }) + .then(() => true) + .catch(() => false); + say(removed, "the printer is removed"); + say(readFileSync(FRAMES_FILE, "utf8").includes("remove_printer"), "the gateway was told to forget it"); + + say(pageErrors.length === 0, "no uncaught page errors", pageErrors.slice(0, 2).join(" | ")); +} finally { + gateway?.kill(); + await browser.close(); +} + +process.exit(failed ? 1 : 0); diff --git a/scripts/fake-gateway-serve.ts b/scripts/fake-gateway-serve.ts index caa80b2..9252454 100644 --- a/scripts/fake-gateway-serve.ts +++ b/scripts/fake-gateway-serve.ts @@ -2,6 +2,10 @@ * A stand-in gateway that stays connected and answers printer provisioning, * for scripts/e2e-provision.sh. * + * With TELEMETRY_AFTER_PROVISION=1 it also reports each provisioned printer + * (running, then failed a few seconds later), so the console has something to + * alert on. + * * Needs FARM_ID in the environment (the hello must name the token's farm). * * Usage: bun scripts/fake-gateway-serve.ts [refuse-access-code] @@ -33,6 +37,14 @@ ws.onmessage = (event) => { const message = JSON.parse(String(event.data)); if (message.type !== "provision_printer" && message.type !== "remove_printer") return; appendFileSync(framesFile, JSON.stringify(message) + "\n"); + if (message.type === "provision_printer" && process.env.TELEMETRY_AFTER_PROVISION === "1") { + const id = message.printer.id; + const report = (state: string) => + ws.send(JSON.stringify({ type: "telemetry", printers: [{ printer_id: id, name: message.printer.name, state, percent: 40, print_error: 0 }] })); + setTimeout(() => report("RUNNING"), 1500); + setTimeout(() => report("RUNNING"), 3000); + setTimeout(() => report("FAILED"), 4500); + } const refused = message.type === "provision_printer" && refuseCode && message.printer.access_code === refuseCode; ws.send( JSON.stringify({