diff --git a/RUNNING.md b/RUNNING.md index 82de3e832..4c4b55238 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -47,8 +47,14 @@ working instance of X" — read them before inventing a boot path. scenario source as `test.ts`. - `cd e2e && bun run serve` builds the viewer and serves the scenario × target matrix over HTTP, bound to all interfaces (reachable over the - tailnet). Individual runs are at `#//` hash routes — when - handing results to the user, link those directly, not the bare matrix. + tailnet). It prefers port 8901 but walks forward to the next free port if + that's taken (so concurrent worktrees, or a leaked previous viewer, never + wedge each other) — read the printed `e2e viewer → …` URL for the actual + port. `PORT=…` pins a port explicitly and fails loudly if it's busy. The + built SPA is port- and mount-agnostic (relative assets + hash routing), so + whatever port it lands on just works. Individual runs are at + `#//` hash routes — when handing results to the user, link + those directly, not the bare matrix. - `bun e2e/scripts/pr-media.ts e2e/runs//` converts a run's recording to a gif, uploads it to the `e2e-media` branch, and prints PR-ready markdown. diff --git a/e2e/scripts/serve.ts b/e2e/scripts/serve.ts index 46b78f37f..a1f53eb97 100644 --- a/e2e/scripts/serve.ts +++ b/e2e/scripts/serve.ts @@ -1,15 +1,24 @@ // Static server for runs/ — the review URL. Supports range requests so the // session videos seek/stream, gzips text assets, and marks vite's hashed // /assets/ as immutable so Monaco/React chunks download once, ever. -// `bun e2e/scripts/serve.ts` → http://host:8901 +// `bun e2e/scripts/serve.ts` → prints the bound URL (default port 8901, but +// it walks forward to the next free port if that's taken, so two worktrees — +// or a leaked previous viewer — never wedge each other). `PORT=…` pins a port +// explicitly and fails loudly if it's busy (the strictPort rule from +// src/ports.ts). The SPA itself is port- and mount-agnostic (relative assets + +// hash routing), so any port the server lands on just works in the browser. import { createReadStream, existsSync, statSync } from "node:fs"; import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { extname, join, normalize } from "node:path"; import { fileURLToPath } from "node:url"; import { createGzip } from "node:zlib"; const ROOT = fileURLToPath(new URL("../runs/", import.meta.url)); -const PORT = Number(process.env.PORT ?? 8901); +// Explicit PORT pins (and fails visibly if busy); otherwise 8901 is just a +// starting preference we walk forward from. +const PINNED = process.env.PORT !== undefined; +const PREFERRED = Number(process.env.PORT ?? 8901); const MIME: Record = { ".html": "text/html; charset=utf-8", @@ -27,7 +36,7 @@ const MIME: Record = { const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]); -createServer((req, res) => { +const server = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://x"); let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, ""); if (path === "" || path === ".") path = "index.html"; @@ -78,4 +87,30 @@ createServer((req, res) => { } res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" }); createReadStream(file).pipe(res); -}).listen(PORT, () => console.log(`e2e viewer → http://localhost:${PORT}/`)); +}); + +// Host omitted → bind every interface (reachable over the tailnet). On a busy +// port: a pinned PORT is a hard error (predictable, matches --strictPort); an +// unpinned default walks forward to the next free port instead of crashing. +const MAX_WALK = 50; +const listen = (port: number, attempt = 0): void => { + server.once("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EADDRINUSE") throw err; + if (PINNED) { + console.error(`e2e viewer: PORT=${port} is in use — free it or pick another port.`); + process.exit(1); + } + if (attempt >= MAX_WALK) { + console.error(`e2e viewer: no free port found in ${PREFERRED}..${PREFERRED + MAX_WALK}.`); + process.exit(1); + } + console.warn(`e2e viewer: port ${port} in use, trying ${port + 1}…`); + listen(port + 1, attempt + 1); + }); + server.listen(port, () => { + const actual = (server.address() as AddressInfo).port; + console.log(`e2e viewer → http://localhost:${actual}/`); + }); +}; + +listen(PREFERRED);