From 39b093ce251bb4f65cb5b0e658d1f5528a598c63 Mon Sep 17 00:00:00 2001 From: Thomas Date: Tue, 29 Sep 2026 18:31:08 -0400 Subject: [PATCH 1/2] Improve browser smoke failure diagnostics - Include page errors and failed requests in startup and reload failures - Verify reset diagnostics and external target restoration --- docs/tests.md | 2 +- tests/frontend/flag_sync_smoke.cjs | 86 ++++++++++++++++++------------ 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/docs/tests.md b/docs/tests.md index c212e59..2430114 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -66,7 +66,7 @@ npm run test:frontend Runs the Playwright smoke test for browser-level shared-state sync. This is also the only suite that can cover the Configure sampler preset panel, because `renderFlags()` destroys and rebuilds it — the `` an assertion reads is a different element than the one that was clicked, which a `node:vm` harness cannot reproduce. +Both browser suites serve `ui/` through the shared Node HTTP/1.1 server in `tests/frontend/ui_server.cjs`. Persistent connections avoid opening a socket for every asset, which can exhaust Windows socket buffers during repeated page loads (`net::ERR_NO_BUFFER_SPACE`). A regression check verifies that HTML, JavaScript, and CSS requests reuse one connection. Python is not needed to serve the browser fixtures. + The browser suite has named `node:test` scenarios with fresh browser contexts and API fixtures. Shared controls, authentication, inference, Chat streaming/recovery/compaction, samplers, external servers, custom launch arguments, backend activation, model switching, preset focus, model folders, and Monitor behavior run independently. A scenario failure does not prevent the remaining scenarios from running. Smoke-test failures, including startup and reload timeouts, retain the original error as their cause and include captured page errors and failed requests, or explicit indications that none were captured. Normal request cancellations during navigation are excluded. Injected Configure rendering failures and connection resets verify these diagnostics on both initial load and reload. The external-server reload check waits for a restored inference target before asserting its exact identity, so a wrong identity is reported directly. To run one scenario: ```powershell diff --git a/tests/frontend/chat_popout_integration.cjs b/tests/frontend/chat_popout_integration.cjs index f618477..94707f5 100644 --- a/tests/frontend/chat_popout_integration.cjs +++ b/tests/frontend/chat_popout_integration.cjs @@ -1,9 +1,9 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); -const http = require("node:http"); const path = require("node:path"); const { randomUUID } = require("node:crypto"); const { test } = require("node:test"); +const { startUiServer } = require("./ui_server.cjs"); const { chromium } = (() => { try { @@ -66,70 +66,6 @@ function representativeConversation() { }; } -function contentType(filePath) { - const ext = path.extname(filePath).toLowerCase(); - return { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".svg": "image/svg+xml", - ".png": "image/png", - ".ico": "image/x-icon", - }[ext] || "application/octet-stream"; -} - -function startUiServer() { - const server = http.createServer((request, response) => { - if (request.method !== "GET" && request.method !== "HEAD") { - response.writeHead(405, { Allow: "GET, HEAD" }); - response.end(); - return; - } - let pathname; - try { - pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); - } catch (error) { - response.writeHead(400); - response.end("Bad request"); - return; - } - const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); - const filePath = path.resolve(UI_ROOT, relative); - if (filePath !== UI_ROOT && !filePath.startsWith(UI_ROOT + path.sep)) { - response.writeHead(403); - response.end("Forbidden"); - return; - } - if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { - response.writeHead(404); - response.end("Not found"); - return; - } - response.writeHead(200, { - "Content-Type": contentType(filePath), - "Cache-Control": "no-store", - }); - if (request.method === "HEAD") response.end(); - else response.end(fs.readFileSync(filePath)); - }); - return new Promise((resolve, reject) => { - const onError = error => reject(error); - server.once("error", onError); - server.listen(0, "127.0.0.1", () => { - server.removeListener("error", onError); - const address = server.address(); - resolve({ - baseUrl: `http://127.0.0.1:${address.port}/`, - async close() { - if (typeof server.closeAllConnections === "function") server.closeAllConnections(); - await new Promise((done, fail) => server.close(error => error ? fail(error) : done())); - }, - }); - }); - }); -} - function installInitScript(context, conversation, runNonce) { return context.addInitScript(({ history, nonce, secret }) => { if (!/^https?:$/.test(location.protocol)) return; diff --git a/tests/frontend/flag_sync_smoke.cjs b/tests/frontend/flag_sync_smoke.cjs index 6c02cb5..70b1438 100644 --- a/tests/frontend/flag_sync_smoke.cjs +++ b/tests/frontend/flag_sync_smoke.cjs @@ -1,12 +1,9 @@ const assert = require("node:assert/strict"); -const { spawn } = require("node:child_process"); const http = require("node:http"); -const path = require("node:path"); const { after, before, test } = require("node:test"); const { character, pngCard } = require("./character_card_fixtures.cjs"); +const { startUiServer } = require("./ui_server.cjs"); -const ROOT = path.resolve(__dirname, "..", ".."); -const UI_DIR = path.join(ROOT, "ui"); const START_PORT = Number(process.env.LLAMA_GUI_SMOKE_PORT || 5240); function loadPlaywright() { @@ -44,45 +41,6 @@ async function findFreePort(startPort) { throw new Error(`No free port found from ${startPort} to ${startPort + 19}`); } -async function startStaticServer(port) { - const python = process.env.PYTHON || "python"; - const server = spawn(python, ["-m", "http.server", String(port), "-d", UI_DIR], { - cwd: ROOT, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - }); - - let stderr = ""; - server.stderr.on("data", (chunk) => { - stderr += chunk.toString(); - }); - - // A spawn failure (python not on PATH, for instance) emits 'error' on the - // ChildProcess. With no listener Node treats that as an unhandled error and - // crashes the runner with a bare trace instead of the diagnostic below. - let spawnError = null; - server.on("error", (error) => { - spawnError = error; - }); - - for (let i = 0; i < 40; i += 1) { - if (spawnError) { - throw new Error( - `Could not start the static server with "${python}": ${spawnError.message}` - + ` (set PYTHON to override)` - ); - } - if (server.exitCode !== null) { - throw new Error(`Static server exited early (${server.exitCode}): ${stderr}`); - } - if (await isPortOpen(port)) return server; - await wait(100); - } - - server.kill(); - throw new Error(`Static server did not become ready on port ${port}`); -} - async function selectSection(page, section) { await page.click(`.nav-item[data-section="${section}"]`); await page.waitForSelector(`#section-${section}`, { state: "visible" }); @@ -4367,14 +4325,36 @@ let server; let port; before(async () => { port = await findFreePort(START_PORT); - server = await startStaticServer(port); + server = await startUiServer(port); browser = await loadPlaywright().chromium.launch({ headless: true }); }); after(async () => { try { if (browser) await browser.close(); } finally { - if (server) server.kill(); + if (server) await server.close(); + } +}); + +test("static assets reuse a keep-alive connection", { timeout: 10000 }, async (t) => { + const agent = new http.Agent({ keepAlive: true, maxSockets: 1 }); + t.after(() => agent.destroy()); + for (const asset of ["/", "/js/monitor/monitor-dom.js?v=1", "/css/tokens.css?v=1"]) { + const result = await new Promise((resolve, reject) => { + const request = http.get({ hostname: "127.0.0.1", port, path: asset, agent }, response => { + response.resume(); + response.on("error", reject); + response.on("end", () => resolve({ + status: response.statusCode, + reusedSocket: request.reusedSocket, + })); + }); + request.on("error", reject); + }); + assert.equal(result.status, 200, `${asset} is served`); + if (asset !== "/") { + assert.equal(result.reusedSocket, true, `${asset} reuses the previous connection`); + } } }); diff --git a/tests/frontend/ui_server.cjs b/tests/frontend/ui_server.cjs new file mode 100644 index 0000000..8d1c1f1 --- /dev/null +++ b/tests/frontend/ui_server.cjs @@ -0,0 +1,73 @@ +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const UI_ROOT = path.resolve(__dirname, "..", "..", "ui"); + +function contentType(filePath) { + const ext = path.extname(filePath).toLowerCase(); + return { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/x-icon", + }[ext] || "application/octet-stream"; +} + +// Reuse connections to avoid Windows socket pressure (net::ERR_NO_BUFFER_SPACE) +// during repeated loads. Python's default HTTP/1.0 server closed after each asset. +function startUiServer(port = 0) { + const server = http.createServer((request, response) => { + if (request.method !== "GET" && request.method !== "HEAD") { + response.writeHead(405, { Allow: "GET, HEAD" }); + response.end(); + return; + } + let pathname; + try { + pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname); + } catch (error) { + response.writeHead(400); + response.end("Bad request"); + return; + } + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const filePath = path.resolve(UI_ROOT, relative); + if (filePath !== UI_ROOT && !filePath.startsWith(UI_ROOT + path.sep)) { + response.writeHead(403); + response.end("Forbidden"); + return; + } + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + response.writeHead(404); + response.end("Not found"); + return; + } + response.writeHead(200, { + "Content-Type": contentType(filePath), + "Cache-Control": "no-store", + }); + if (request.method === "HEAD") response.end(); + else response.end(fs.readFileSync(filePath)); + }); + return new Promise((resolve, reject) => { + const onError = error => reject(error); + server.once("error", onError); + server.listen(port, "127.0.0.1", () => { + server.removeListener("error", onError); + const address = server.address(); + resolve({ + baseUrl: `http://127.0.0.1:${address.port}/`, + async close() { + if (typeof server.closeAllConnections === "function") server.closeAllConnections(); + await new Promise((done, fail) => server.close(error => error ? fail(error) : done())); + }, + }); + }); + }); +} + +module.exports = { startUiServer };