pi-remote-control/scripts/smoke/smoke.mjs

250 lines
8.8 KiB
JavaScript

/**
* 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 <svg.
* 4. GET / — 200 (with auth token), text/html, contains HTML marker.
* 5. WS /ws — successful 101 upgrade (with auth token). Close cleanly.
* 6. Teardown — SIGTERM pi, process exits.
*
* Auth strategy:
* The server requires a token on GET / and WS /ws. We pre-seed a known token
* in a temporary HOME directory ($HOME/.pi/remote-control/token) and pass it
* via the ?token= query parameter using the existing mechanism. No production
* code is modified.
*
* Port strategy:
* We write bindAddress + publicBaseUrl to the temporary config so the server
* binds to a deterministic port (SMOKE_PORT env, default 19876). No real
* ~/.pi files are touched.
*/
import assert from "node:assert/strict";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { fileURLToPath } from "node:url";
import {
baseUrl,
closeWebSocket,
createSmokeHome,
fetchText,
killPi,
openWebSocket,
removeSmokeHome,
spawnPi,
waitForPort,
} from "./helpers.mjs";
// ── Configuration ─────────────────────────────────────────────────────────────
const SMOKE_PORT = Number(process.env.SMOKE_PORT ?? 19876);
const SMOKE_TOKEN = "smoke-test-token-deterministic-1234";
const EXTENSION_PATH = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../extensions/remote-control",
);
// ── Suite ─────────────────────────────────────────────────────────────────────
describe("remote-control smoke tests", () => {
/** @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 <svg", async () => {
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("<svg"),
`Expected body to start with <svg, got: ${body.slice(0, 100)}`,
);
});
// ── Test 3: root with auth ──────────────────────────────────────────────────
it("GET / (with token) → 200 text/html containing HTML marker", async () => {
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("<!DOCTYPE html>"),
"Expected body to contain <!DOCTYPE html>",
);
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",
);
});
});