250 lines
8.8 KiB
JavaScript
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",
|
|
);
|
|
});
|
|
});
|