268 lines
8.4 KiB
JavaScript
268 lines
8.4 KiB
JavaScript
/**
|
|
* 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<string>} 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<number|null>} 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<void>} 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<import("ws").WebSocket>}
|
|
*/
|
|
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<void>}
|
|
*/
|
|
export function closeWebSocket(ws) {
|
|
return new Promise((resolve) => {
|
|
if (ws.readyState === ws.constructor.CLOSED) {
|
|
resolve();
|
|
return;
|
|
}
|
|
ws.once("close", () => resolve());
|
|
ws.close();
|
|
});
|
|
}
|