Files
continuum-backend/scripts/e2e-setup-browser.ts
T
octoturge 9e4b3e5315 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.
2026-10-29 15:15:06 +00:00

159 lines
7.2 KiB
TypeScript

/**
* 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);