diff --git a/docs/SYNC.md b/docs/SYNC.md index 6bd6a84..c174c27 100644 --- a/docs/SYNC.md +++ b/docs/SYNC.md @@ -86,6 +86,7 @@ add a row or open a Contract Change Request. | `extensions/remote-control/apns/**` | T-1.10, Phase-2 T-2.9 (when iOS supplies tokens) | | `extensions/remote-control/cli/**` | T-1.3, T-1.7 | | `extensions/remote-control/config.ts` | T-1.7 | +| `scripts/smoke/**` | T-1.0a (harness), any T-1.* task (add new test files) | | `docs/SYNC.md` | all (this file) | | `docs/PHASE-*.md` | nobody once a phase has started (frozen plan) — open a CCR to amend | | `docs/reference/**` | nobody during implementation — archival | @@ -160,4 +161,5 @@ yyyy-mm-dd @handle T-x.y what was done 2026-05-15 @worker-phase0 T-0.* Phase 0 spike complete. tmux+pipe-pane PoC validated. GREEN LIGHT for Phase 1. Report: reference/PHASE-0-report.md. Branch: feat/spike-stream (kept for reference, not merged). 2026-05-15 @worker-phase0.5 T-0.5 Phase 0.5 spike complete. tmux control mode validated. VERDICT: Path B recommended. Report: reference/PHASE-0.5-report.md. Branch: feat/spike-tmux-cc (kept for reference, not merged). 2026-05-15 @worker-t1.0 T-1.0 Server refactor scaffold complete. server.ts carved into server/{types,server,upgrade}.ts + server/routes/ stub. LEGACY html path preserved end-to-end. Branch: feat/p1-t1-0-server-refactor. +2026-05-15 @worker-t1.0a T-1.0a Smoke test harness MVP complete. scripts/smoke/ with helpers.mjs + smoke.mjs. 6 tests: manifest, icon, GET/ auth+unauth, WS upgrade, process-alive. All green in ~1.4s. Uses python3 pty.spawn for PTY allocation. Branch: feat/p1-t1-0a-smoke. ``` diff --git a/package.json b/package.json index 480c749..69fea3e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "scripts": { "lint": "biome check --write .", "lint:check": "biome check .", - "prepare": "node .husky/install.mjs" + "prepare": "node .husky/install.mjs", + "smoke": "node --test scripts/smoke/smoke.mjs" }, "devDependencies": { "@biomejs/biome": "^2.4.12", diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md new file mode 100644 index 0000000..c2f568d --- /dev/null +++ b/scripts/smoke/README.md @@ -0,0 +1,96 @@ +# Smoke Test Harness + +End-to-end smoke tests for the `remote-control` extension. These tests spawn +a real `pi` subprocess with the extension loaded and hit the HTTP/WebSocket +endpoints to verify they respond correctly. + +## Running + +```sh +npm run smoke +``` + +Or directly: + +```sh +node --test scripts/smoke/smoke.mjs +``` + +## What is tested (MVP — T-1.0a) + +| # | Test | Description | +|---|------|-------------| +| 1 | Server starts | pi spawns with `--remote-control`; TCP port opens within 12 s | +| 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 `` so **no real `~/.pi` files are read or +written**. The temp directory is deleted in the `after()` teardown hook even +if tests fail. + +## Authentication + +The server requires either a valid session cookie or `?token=` query +parameter. The smoke tests pre-seed a known token (`smoke-test-token-deterministic-1234`) +and pass it in the `?token=` parameter. No production auth logic is modified. + +## Adding new tests + +1. Create a new file `scripts/smoke/.test.mjs` +2. Import helpers from `./helpers.mjs` +3. Import your test file from `smoke.mjs` (or run it standalone with `node --test`) + +Example: + +```js +// scripts/smoke/stream.test.mjs +import { describe, it } from "node:test"; +import { openWebSocket, closeWebSocket } from "./helpers.mjs"; + +export function registerStreamTests(port, token) { + describe("stream tests", () => { + it("WS /sessions/:id/stream → 101", async () => { + // ... + }); + }); +} +``` + +## Flags used + +Same flags as `make dev`: +``` +pi -nt -ne -ns -np -nc --no-session --offline -e extensions/remote-control --remote-control +``` + +## Troubleshooting + +**Port busy**: if `SMOKE_PORT` is already in use, the test will fail at the +`waitForPort` step. Change the port: `SMOKE_PORT=20001 npm run smoke`. + +**pi not found**: ensure `pi` is on your `PATH`. Check with `which pi`. + +**Server not starting**: run with verbose output to see what pi logs: +```sh +node --test --reporter spec scripts/smoke/smoke.mjs +``` +The port-wait error will include captured stdout/stderr from pi. diff --git a/scripts/smoke/helpers.mjs b/scripts/smoke/helpers.mjs new file mode 100644 index 0000000..c80e212 --- /dev/null +++ b/scripts/smoke/helpers.mjs @@ -0,0 +1,267 @@ +/** + * Smoke-test helpers — spawn-pi, wait-for-port, fetch, WebSocket. + * + * Designed to be imported by smoke.mjs and any future test files + * (e.g. scripts/smoke/stream.test.mjs). All helpers are stateless; + * the caller is responsible for lifecycle. + */ + +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import { createRequire } from "node:module"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +// ── WebSocket (from project's bundled ws dependency) ───────────────────────── + +const _require = createRequire(import.meta.url); +/** @type {import("ws")} */ +const wsModule = _require("ws"); +const WebSocket = wsModule.WebSocket ?? wsModule.default ?? wsModule; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/** Milliseconds to wait for the TCP port to open before giving up. */ +const PORT_READY_TIMEOUT_MS = 12_000; +/** Milliseconds between TCP-port-ready poll attempts. */ +const PORT_POLL_INTERVAL_MS = 100; + +// ── Temporary HOME setup ────────────────────────────────────────────────────── + +/** + * Create a minimal fake HOME directory that satisfies remote-control's config + * requirements: + * $HOME/.pi/remote-control/config.json — publicBaseUrl + bindAddress + * $HOME/.pi/remote-control/token — known token for auth + * + * @param {object} opts + * @param {number} opts.port - TCP port to bind to. + * @param {string} opts.token - Auth token to pre-seed. + * @returns {Promise} Path to the temp HOME directory. + */ +export async function createSmokeHome({ port, token }) { + const tmpBase = await fs.mkdtemp(path.join(os.tmpdir(), "pi-smoke-")); + const rcDir = path.join(tmpBase, ".pi", "remote-control"); + await fs.mkdir(rcDir, { recursive: true }); + + const config = { + publicBaseUrl: `http://127.0.0.1:${port}`, + bindAddress: `127.0.0.1:${port}`, + }; + await fs.writeFile( + path.join(rcDir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); + await fs.writeFile(path.join(rcDir, "token"), token, { + encoding: "utf8", + mode: 0o600, + }); + + return tmpBase; +} + +/** + * Remove the temp HOME directory created by createSmokeHome. + * Silently ignores errors (e.g. already deleted). + * + * @param {string} tmpHome + */ +export async function removeSmokeHome(tmpHome) { + try { + await fs.rm(tmpHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } +} + +// ── Pi subprocess ───────────────────────────────────────────────────────────── + +/** + * Spawn a `pi` process with the remote-control extension loaded. + * + * Uses the same flags as `make dev` plus `--remote-control` to auto-start + * the HTTP server. Sets HOME to the given fake home so config/token paths + * resolve correctly without touching the real ~/.pi directory. + * + * Pi is a TUI application that requires a PTY (pseudo-terminal) to enter its + * interactive session mode and fire `session_start`. We use Python's built-in + * `pty.spawn` to allocate a PTY for pi; this is available on all macOS/Linux + * systems without any additional dependencies. + * + * @param {object} opts + * @param {string} opts.extensionPath - Absolute path to the extension dir. + * @param {string} opts.fakeHome - Path to the fake HOME directory. + * @returns {{ proc: import("node:child_process").ChildProcess, logs: string[] }} + */ +export function spawnPi({ extensionPath, fakeHome }) { + const logs = []; + + // Python script that allocates a PTY and spawns pi inside it. + // Using pty.spawn() keeps the child alive (no EOF injection like `script` does). + const pyScript = [ + "import os, pty", + `os.environ['HOME'] = ${JSON.stringify(fakeHome)}`, + `pty.spawn(['pi', '-nt', '-ne', '-ns', '-np', '-nc', '--no-session', '--offline', '-e', ${JSON.stringify(extensionPath)}, '--remote-control'])`, + ].join("\n"); + + const proc = spawn("python3", ["-c", pyScript], { + env: { ...process.env, HOME: fakeHome }, + stdio: ["ignore", "pipe", "pipe"], + }); + + const capture = (chunk) => { + logs.push(chunk.toString()); + }; + proc.stdout.on("data", capture); + proc.stderr.on("data", capture); + + return { proc, logs }; +} + +/** + * Kill a pi process and wait for it to exit. + * Sends SIGTERM first; if it doesn't exit within 3 s, sends SIGKILL. + * + * @param {import("node:child_process").ChildProcess} proc + * @returns {Promise} The exit code (or null if killed by signal). + */ +export function killPi(proc) { + return new Promise((resolve) => { + if (proc.exitCode !== null) { + resolve(proc.exitCode); + return; + } + + let settled = false; + const settle = (code) => { + if (!settled) { + settled = true; + clearTimeout(sigkillTimer); + resolve(code ?? null); + } + }; + + proc.once("exit", (code) => settle(code)); + + proc.kill("SIGTERM"); + + // Safety: escalate to SIGKILL after 3 s + const sigkillTimer = setTimeout(() => { + try { + proc.kill("SIGKILL"); + } catch { + /* already dead */ + } + }, 3_000); + }); +} + +// ── Port readiness ──────────────────────────────────────────────────────────── + +/** + * Poll a TCP port until it accepts connections or the timeout expires. + * + * @param {object} opts + * @param {number} opts.port + * @param {string} [opts.host] - Default "127.0.0.1". + * @param {number} [opts.timeoutMs] - Default PORT_READY_TIMEOUT_MS. + * @param {number} [opts.intervalMs] - Default PORT_POLL_INTERVAL_MS. + * @returns {Promise} Resolves when port is open; rejects on timeout. + */ +export function waitForPort({ + port, + host = "127.0.0.1", + timeoutMs = PORT_READY_TIMEOUT_MS, + intervalMs = PORT_POLL_INTERVAL_MS, +}) { + return new Promise((resolve, reject) => { + const deadline = Date.now() + timeoutMs; + + const attempt = () => { + const sock = net.createConnection({ port, host }); + + sock.once("connect", () => { + sock.destroy(); + resolve(); + }); + + sock.once("error", () => { + sock.destroy(); + if (Date.now() >= deadline) { + reject( + new Error( + `Port ${host}:${port} did not open within ${timeoutMs} ms`, + ), + ); + } else { + setTimeout(attempt, intervalMs); + } + }); + }; + + attempt(); + }); +} + +// ── HTTP helpers ────────────────────────────────────────────────────────────── + +/** + * Build the base URL for the smoke server. + * + * @param {number} port + * @returns {string} + */ +export function baseUrl(port) { + return `http://127.0.0.1:${port}`; +} + +/** + * Fetch a URL and return the response plus text body. + * Throws if the network request itself fails. + * + * @param {string} url + * @param {RequestInit} [init] + * @returns {Promise<{ res: Response; body: string }>} + */ +export async function fetchText(url, init) { + const res = await fetch(url, init); + const body = await res.text(); + return { res, body }; +} + +// ── WebSocket helpers ───────────────────────────────────────────────────────── + +/** + * Open a WebSocket connection and wait for either an `open` or `error` event. + * Resolves with the WebSocket instance on success. + * Rejects with the error on failure. + * + * @param {string} url - WebSocket URL (ws:// or wss://). + * @returns {Promise} + */ +export function openWebSocket(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +/** + * Close a WebSocket and wait for the close event. + * + * @param {import("ws").WebSocket} ws + * @returns {Promise} + */ +export function closeWebSocket(ws) { + return new Promise((resolve) => { + if (ws.readyState === ws.constructor.CLOSED) { + resolve(); + return; + } + ws.once("close", () => resolve()); + ws.close(); + }); +} diff --git a/scripts/smoke/smoke.mjs b/scripts/smoke/smoke.mjs new file mode 100644 index 0000000..ed909d4 --- /dev/null +++ b/scripts/smoke/smoke.mjs @@ -0,0 +1,249 @@ +/** + * 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", + ); + }); +});