/** * Smoke test — end-to-end validation of the remote-control HTTP/WS server. * * Runs with: node --test scripts/smoke/smoke.mjs * Or: npm run smoke * * What is tested (MVP): * 1. Server starts — pi spawns with --remote-control and the HTTP port opens. * 2. GET /manifest.json — 200, application/manifest+json, parses, has name/short_name/start_url. * 3. GET /icon.svg — 200, image/svg+xml, body starts with { /** @type {import("node:child_process").ChildProcess} */ let proc; /** @type {string} */ let fakeHome; /** @type {string[]} */ let logs; before(async () => { // Create temp HOME with config + token fakeHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN }); // Spawn pi ({ proc, logs } = spawnPi({ extensionPath: EXTENSION_PATH, fakeHome })); // Wait for the HTTP server to open the TCP port try { await waitForPort({ port: SMOKE_PORT }); } catch (err) { // Attach captured logs to the error for easy debugging const captured = logs.join(""); throw new Error( `${err.message}\n\n--- pi stdout/stderr ---\n${captured || "(empty)"}`, ); } }); after(async () => { // Always clean up — kill pi and remove temp HOME if (proc) { await killPi(proc); } if (fakeHome) { await removeSmokeHome(fakeHome); } }); // ── Test 1: manifest.json ─────────────────────────────────────────────────── it("GET /manifest.json → 200 application/manifest+json with required fields", async () => { const { res, body } = await fetchText( `${baseUrl(SMOKE_PORT)}/manifest.json`, ); assert.equal(res.status, 200, `Expected 200, got ${res.status}`); const ct = res.headers.get("content-type") ?? ""; assert.ok( ct.includes("application/manifest+json"), `Expected content-type application/manifest+json, got: ${ct}`, ); let manifest; try { manifest = JSON.parse(body); } catch { assert.fail( `manifest.json body is not valid JSON: ${body.slice(0, 200)}`, ); } assert.ok( typeof manifest.name === "string" && manifest.name.length > 0, "manifest.name must be a non-empty string", ); assert.ok( typeof manifest.short_name === "string" && manifest.short_name.length > 0, "manifest.short_name must be a non-empty string", ); assert.ok( typeof manifest.start_url === "string" && manifest.start_url.length > 0, "manifest.start_url must be a non-empty string", ); }); // ── Test 2: icon.svg ──────────────────────────────────────────────────────── it("GET /icon.svg → 200 image/svg+xml, body starts with { const { res, body } = await fetchText(`${baseUrl(SMOKE_PORT)}/icon.svg`); assert.equal(res.status, 200, `Expected 200, got ${res.status}`); const ct = res.headers.get("content-type") ?? ""; assert.ok( ct.includes("image/svg+xml"), `Expected content-type image/svg+xml, got: ${ct}`, ); assert.ok( body.trimStart().startsWith(" { const url = `${baseUrl(SMOKE_PORT)}/?token=${SMOKE_TOKEN}`; // The server issues a 302 redirect on first token auth (sets session cookie), // then serves 200 on the redirect target. Follow redirects manually to keep // cookies across the redirect chain. const cookieJar = {}; // First request: expect 302 const first = await fetch(url, { redirect: "manual" }); let body; if (first.status === 302) { // Extract session cookie for (const [name, value] of first.headers.entries()) { if (name.toLowerCase() === "set-cookie") { const match = value.match(/^([^=]+)=([^;]+)/); if (match) cookieJar[match[1]] = match[2]; } } const location = first.headers.get("location") ?? "/"; const cookieHeader = Object.entries(cookieJar) .map(([k, v]) => `${k}=${v}`) .join("; "); const second = await fetch(`${baseUrl(SMOKE_PORT)}${location}`, { headers: cookieHeader ? { Cookie: cookieHeader } : {}, }); assert.equal( second.status, 200, `Expected 200 on redirect target, got ${second.status}`, ); const ct = second.headers.get("content-type") ?? ""; assert.ok( ct.includes("text/html"), `Expected content-type text/html, got: ${ct}`, ); body = await second.text(); } else { assert.equal(first.status, 200, `Expected 200, got ${first.status}`); const ct = first.headers.get("content-type") ?? ""; assert.ok( ct.includes("text/html"), `Expected content-type text/html, got: ${ct}`, ); body = await first.text(); } // Check for a marker from html.ts — the title is always present assert.ok( body.includes(""), "Expected body to contain ", ); assert.ok( body.includes("remote-control") || body.includes("Pi Remote") || body.includes("π"), "Expected body to contain a known HTML marker (remote-control / Pi Remote / π)", ); }); // ── Test 4: 403 on unauthenticated root ───────────────────────────────────── it("GET / (no token) → 403 Forbidden", async () => { const { res } = await fetchText(`${baseUrl(SMOKE_PORT)}/`); assert.equal( res.status, 403, `Expected 403 without token, got ${res.status}`, ); }); // ── Test 5: WebSocket upgrade ─────────────────────────────────────────────── it("WS /ws (with token) → successful 101 upgrade", async () => { const wsUrl = `ws://127.0.0.1:${SMOKE_PORT}/ws?token=${SMOKE_TOKEN}`; // First, get a session cookie via HTTP (WS token auth also works directly // since isAuthenticated checks URL searchParams on the Upgrade request) const ws = await openWebSocket(wsUrl); assert.ok( ws.readyState === ws.constructor.OPEN, "WebSocket should be in OPEN state after successful handshake", ); await closeWebSocket(ws); }); // ── Test 6: teardown (handled in after(), verified here) ─────────────────── it("pi process is alive during tests", () => { assert.equal( proc.exitCode, null, "pi process should still be running during tests", ); }); });