Drive the first-run experience in a real browser

e2e-setup-browser.sh signs a user with no organisation into the real web
console, creates a farm, copies the gateway settings the page shows,
connects a stand-in gateway configured from exactly those settings, adds a
printer through the form, gets a failed-print alert and dismisses it, then
removes the printer. Afterwards it searches every table and the backend log
for the access code the browser typed. No printer is touched and dispatch
is off. The e2e scripts that start the real proxy also give it its own store
file in the scratch directory.
This commit is contained in:
2026-10-29 15:15:06 +00:00
parent 519ada57f3
commit 9e4b3e5315
5 changed files with 309 additions and 0 deletions
+1
View File
@@ -274,6 +274,7 @@ env -C "$PROXY_DIR" \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \ CONTINUUM_STREAM_BASE_URL="http://e2e-gateway.invalid:1984" \
CONTINUUM_PRINTERS_PATH=printers.toml \ CONTINUUM_PRINTERS_PATH=printers.toml \
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
RUST_LOG=info \ RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$! PROXY_PID=$!
+1
View File
@@ -220,6 +220,7 @@ env -C "$PROXY_DIR" \
CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \ CONTINUUM_GO2RTC_CONFIG="$WORK/go2rtc.yaml" \
CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \ CONTINUUM_STREAM_BASE_URL="$STREAM_BASE_URL" \
CONTINUUM_PRINTERS_PATH=printers.toml \ CONTINUUM_PRINTERS_PATH=printers.toml \
CONTINUUM_STORE_PATH="$WORK/continuum.db" \
RUST_LOG=info \ RUST_LOG=info \
"$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 & "$PROXY_DIR/target/debug/continuum-proxy" > "$WORK/proxy.log" 2>&1 &
PROXY_PID=$! PROXY_PID=$!
+137
View File
@@ -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."
+158
View File
@@ -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 `ok<TAB>label` / `FAILED<TAB>label`. 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<string, string>;
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<typeof Bun.spawn> | 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);
+12
View File
@@ -2,6 +2,10 @@
* A stand-in gateway that stays connected and answers printer provisioning, * A stand-in gateway that stays connected and answers printer provisioning,
* for scripts/e2e-provision.sh. * 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). * Needs FARM_ID in the environment (the hello must name the token's farm).
* *
* Usage: bun scripts/fake-gateway-serve.ts <ws-url> <edge-jwt> <seconds> <frames-file> [refuse-access-code] * Usage: bun scripts/fake-gateway-serve.ts <ws-url> <edge-jwt> <seconds> <frames-file> [refuse-access-code]
@@ -33,6 +37,14 @@ ws.onmessage = (event) => {
const message = JSON.parse(String(event.data)); const message = JSON.parse(String(event.data));
if (message.type !== "provision_printer" && message.type !== "remove_printer") return; if (message.type !== "provision_printer" && message.type !== "remove_printer") return;
appendFileSync(framesFile, JSON.stringify(message) + "\n"); 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; const refused = message.type === "provision_printer" && refuseCode && message.printer.access_code === refuseCode;
ws.send( ws.send(
JSON.stringify({ JSON.stringify({