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