The viewer's static server pinned port 8901 with no error handler, so it hard-crashed on EADDRINUSE — exactly the collision the per-checkout port machinery exists to prevent. Two worktrees, or a leaked previous viewer, would wedge each other. The server now walks forward from 8901 to the next free port and prints the actual bound URL. An explicit PORT still pins strictly and fails loudly if busy (the strictPort rule). The built SPA was already port- and mount-agnostic (relative assets + hash routing), so whatever port the server lands on just works in the browser.
Unified · Split
| @@ -47,8 +47,14 @@ working instance of X" — read them before inventing a boot path. · 46 unmodified lines | |||
| 47 | scenario source as `test.ts`. | 47 | scenario source as `test.ts`. |
| 48 | - `cd e2e && bun run serve` builds the viewer and serves the scenario × | 48 | - `cd e2e && bun run serve` builds the viewer and serves the scenario × |
| 49 | target matrix over HTTP, bound to all interfaces (reachable over the | 49 | target matrix over HTTP, bound to all interfaces (reachable over the |
| 50 | tailnet). Individual runs are at `#/<target>/<slug>` hash routes — when | 50 | tailnet). It prefers port 8901 but walks forward to the next free port if |
| 51 | handing results to the user, link those directly, not the bare matrix. | 51 | that's taken (so concurrent worktrees, or a leaked previous viewer, never |
| 52 | wedge each other) — read the printed `e2e viewer → …` URL for the actual | ||
| 53 | port. `PORT=…` pins a port explicitly and fails loudly if it's busy. The | ||
| 54 | built SPA is port- and mount-agnostic (relative assets + hash routing), so | ||
| 55 | whatever port it lands on just works. Individual runs are at | ||
| 56 | `#/<target>/<slug>` hash routes — when handing results to the user, link | ||
| 57 | those directly, not the bare matrix. | ||
| 52 | - `bun e2e/scripts/pr-media.ts e2e/runs/<target>/<slug>` converts a run's | 58 | - `bun e2e/scripts/pr-media.ts e2e/runs/<target>/<slug>` converts a run's |
| 53 | recording to a gif, uploads it to the `e2e-media` branch, and prints | 59 | recording to a gif, uploads it to the `e2e-media` branch, and prints |
| 54 | PR-ready markdown. | 60 | PR-ready markdown. |
| @@ -1,15 +1,24 @@ | |||
| 1 | // Static server for runs/ — the review URL. Supports range requests so the | 1 | // Static server for runs/ — the review URL. Supports range requests so the |
| 2 | // session videos seek/stream, gzips text assets, and marks vite's hashed | 2 | // session videos seek/stream, gzips text assets, and marks vite's hashed |
| 3 | // /assets/ as immutable so Monaco/React chunks download once, ever. | 3 | // /assets/ as immutable so Monaco/React chunks download once, ever. |
| 4 | // `bun e2e/scripts/serve.ts` → http://host:8901 | 4 | // `bun e2e/scripts/serve.ts` → prints the bound URL (default port 8901, but |
| 5 | // it walks forward to the next free port if that's taken, so two worktrees — | ||
| 6 | // or a leaked previous viewer — never wedge each other). `PORT=…` pins a port | ||
| 7 | // explicitly and fails loudly if it's busy (the strictPort rule from | ||
| 8 | // src/ports.ts). The SPA itself is port- and mount-agnostic (relative assets + | ||
| 9 | // hash routing), so any port the server lands on just works in the browser. | ||
| 5 | import { createReadStream, existsSync, statSync } from "node:fs"; | 10 | import { createReadStream, existsSync, statSync } from "node:fs"; |
| 6 | import { createServer } from "node:http"; | 11 | import { createServer } from "node:http"; |
| 12 | import type { AddressInfo } from "node:net"; | ||
| 7 | import { extname, join, normalize } from "node:path"; | 13 | import { extname, join, normalize } from "node:path"; |
| 8 | import { fileURLToPath } from "node:url"; | 14 | import { fileURLToPath } from "node:url"; |
| 9 | import { createGzip } from "node:zlib"; | 15 | import { createGzip } from "node:zlib"; |
| 10 | 16 | ||
| 11 | const ROOT = fileURLToPath(new URL("../runs/", import.meta.url)); | 17 | const ROOT = fileURLToPath(new URL("../runs/", import.meta.url)); |
| 12 | const PORT = Number(process.env.PORT ?? 8901); | 18 | // Explicit PORT pins (and fails visibly if busy); otherwise 8901 is just a |
| 19 | // starting preference we walk forward from. | ||
| 20 | const PINNED = process.env.PORT !== undefined; | ||
| 21 | const PREFERRED = Number(process.env.PORT ?? 8901); | ||
| 13 | 22 | ||
| 14 | const MIME: Record<string, string> = { | 23 | const MIME: Record<string, string> = { |
| 15 | ".html": "text/html; charset=utf-8", | 24 | ".html": "text/html; charset=utf-8", |
| @@ -27,7 +36,7 @@ const MIME: Record<string, string> = { · 11 unmodified lines | |||
| 27 | 36 | ||
| 28 | const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]); | 37 | const COMPRESSIBLE = new Set([".html", ".js", ".css", ".map", ".svg", ".json", ".ts"]); |
| 29 | 38 | ||
| 30 | createServer((req, res) => { | 39 | const server = createServer((req, res) => { |
| 31 | const url = new URL(req.url ?? "/", "http://x"); | 40 | const url = new URL(req.url ?? "/", "http://x"); |
| 32 | let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, ""); | 41 | let path = normalize(decodeURIComponent(url.pathname)).replace(/^([/\\])+/, ""); |
| 33 | if (path === "" || path === ".") path = "index.html"; | 42 | if (path === "" || path === ".") path = "index.html"; |
| @@ -78,4 +87,30 @@ createServer((req, res) => { · 44 unmodified lines | |||
| 78 | } | 87 | } |
| 79 | res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" }); | 88 | res.writeHead(200, { "content-type": type, "content-length": size, "accept-ranges": "bytes" }); |
| 80 | createReadStream(file).pipe(res); | 89 | createReadStream(file).pipe(res); |
| 81 | }).listen(PORT, () => console.log(`e2e viewer → http://localhost:${PORT}/`)); | 90 | }); |
| 91 | |||
| 92 | // Host omitted → bind every interface (reachable over the tailnet). On a busy | ||
| 93 | // port: a pinned PORT is a hard error (predictable, matches --strictPort); an | ||
| 94 | // unpinned default walks forward to the next free port instead of crashing. | ||
| 95 | const MAX_WALK = 50; | ||
| 96 | const listen = (port: number, attempt = 0): void => { | ||
| 97 | server.once("error", (err: NodeJS.ErrnoException) => { | ||
| 98 | if (err.code !== "EADDRINUSE") throw err; | ||
| 99 | if (PINNED) { | ||
| 100 | console.error(`e2e viewer: PORT=${port} is in use — free it or pick another port.`); | ||
| 101 | process.exit(1); | ||
| 102 | } | ||
| 103 | if (attempt >= MAX_WALK) { | ||
| 104 | console.error(`e2e viewer: no free port found in ${PREFERRED}..${PREFERRED + MAX_WALK}.`); | ||
| 105 | process.exit(1); | ||
| 106 | } | ||
| 107 | console.warn(`e2e viewer: port ${port} in use, trying ${port + 1}…`); | ||
| 108 | listen(port + 1, attempt + 1); | ||
| 109 | }); | ||
| 110 | server.listen(port, () => { | ||
| 111 | const actual = (server.address() as AddressInfo).port; | ||
| 112 | console.log(`e2e viewer → http://localhost:${actual}/`); | ||
| 113 | }); | ||
| 114 | }; | ||
| 115 | |||
| 116 | listen(PREFERRED); | ||