Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,9 @@ npm test
of edit to its required check, and [`docs/tests.md`](docs/tests.md)
catalogs what each suite covers. A single backend test file runs like
`.venv\Scripts\python.exe -m unittest tests.backend.test_docs_sync -v`.
- The Playwright smoke tests serve `ui/` through `python -m http.server`;
if `python` is not on your PATH, point the `PYTHON` environment variable
at an interpreter.
- The Playwright browser tests serve `ui/` through a shared Node HTTP/1.1
server with persistent connections; no Python interpreter is needed for
these browser fixtures.
- **Playwright is dev/CI-only.** Never add it to `requirements.txt`, launch
scripts, or the Pinokio setup — runtime installs stay Python-only.

Expand Down
4 changes: 3 additions & 1 deletion docs/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ 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 `<select>` an assertion reads is a different element than the one that was clicked, which a `node:vm` harness cannot reproduce.

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 startup failures retain the original error as their cause and include captured page errors, or an explicit indication that none were captured. An injected Configure rendering failure verifies the startup-timeout diagnostic. To run one scenario:
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
node --test --test-name-pattern="benchmark actions" tests/frontend/flag_sync_smoke.cjs
Expand Down
66 changes: 1 addition & 65 deletions tests/frontend/chat_popout_integration.cjs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
156 changes: 77 additions & 79 deletions tests/frontend/flag_sync_smoke.cjs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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" });
Expand Down Expand Up @@ -1662,13 +1620,14 @@ async function verifyBenchmarkActions(page) {
async function runScenario(browser, port, verify) {
const page = await browser.newPage();
await page.addInitScript(() => { window.__LLAMA_GUI_TEST_HOOKS__ = true; });
const pageErrors = [];
const failedRequests = [];
try {
const chatCompletionBodies = [];
const chatCompletionHeaders = [];
const launchBodies = [];
const metricsHeaders = [];
const slotsHeaders = [];
const pageErrors = [];
const lifecycleWarnings = [];
page.on("console", message => {
if (message.type() === "warning" && message.text().includes("Process lifecycle subscriber failed")) {
Expand Down Expand Up @@ -1770,6 +1729,13 @@ async function runScenario(browser, port, verify) {
page.on("pageerror", (error) => {
pageErrors.push(error.message || String(error));
});
page.on("requestfailed", request => {
const failure = request.failure()?.errorText || "unknown error";
// Reloads normally cancel requests belonging to the old document.
if (failure !== "net::ERR_ABORTED") {
failedRequests.push(`${request.method()} ${request.url()}: ${failure}`);
}
});
await page.route("**/api/**", async (route) => {
const url = new URL(route.request().url());
const pathName = url.pathname;
Expand Down Expand Up @@ -2124,13 +2090,9 @@ async function runScenario(browser, port, verify) {
});
});

try {
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => window.LlamaGui?.flagCore && window.LlamaGui?.configFlagsUi);
await page.waitForSelector("#flag-ctx_size", { state: "attached" });
} catch (error) {
throw new Error(`${error.message}\nPage errors during startup:\n${pageErrors.join("\n") || "(none captured)"}`, { cause: error });
}
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => window.LlamaGui?.flagCore && window.LlamaGui?.configFlagsUi);
await page.waitForSelector("#flag-ctx_size", { state: "attached" });
assert.deepEqual(lifecycleWarnings, [], "initial lifecycle rendering must have configured dependencies");

if (typeof verify === "function") {
Expand Down Expand Up @@ -3678,7 +3640,7 @@ async function runScenario(browser, port, verify) {
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForFunction(() => window.LlamaGui?.flagCore && window.LlamaGui?.monitorUi);
await page.waitForFunction(() => typeof inferenceStats !== "undefined"
&& inferenceStats.getTargetKey() === "ext:0:127.0.0.1:9002");
&& inferenceStats.getTargetKey());
assert.equal(await page.evaluate(() => inferenceStats.getTargetKey()), "ext:0:127.0.0.1:9002",
"an already-active target must not mint a new external revision");
assert.equal(
Expand Down Expand Up @@ -4350,6 +4312,9 @@ async function runScenario(browser, port, verify) {
await scenarios[verify]();

assert.equal(pageErrors.length, 0, pageErrors.join("\n"));
} catch (error) {
throw new Error(`${error.message}\nPage errors:\n${pageErrors.join("\n") || "(none captured)"}`
+ `\nFailed requests:\n${failedRequests.join("\n") || "(none captured)"}`, { cause: error });
} finally {
await page.close();
}
Expand All @@ -4360,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`);
}
}
});

Expand Down Expand Up @@ -4510,31 +4497,42 @@ async function verifyOfficialBackendDiscovery(page) {
assert.deepEqual(writes, [], "discovery never installs or switches toolkits");
}

test("startup timeouts report page errors", { timeout: 45000 }, async () => {
const failingBrowser = {
async newPage() {
const page = await browser.newPage();
page.setDefaultTimeout(5000);
await page.addInitScript(() => {
document.addEventListener("DOMContentLoaded", () => {
window.LlamaGui.configFlagsUi.renderFlags = () => {
throw new Error("Injected Configure startup failure");
};
}, { once: true });
});
return page;
},
};
await assert.rejects(
runScenario(failingBrowser, port, () => assert.fail("scenario must not run after failed startup")),
error => {
assert.equal(error.cause?.name, "TimeoutError");
assert.match(error.message, /#flag-ctx_size/);
assert.match(error.message, /Page errors during startup:\nInjected Configure startup failure/);
return true;
},
);
});
for (const phase of ["startup", "reload"]) {
test(`${phase} timeouts report page errors`, { timeout: 45000 }, async () => {
const failStartup = () => {
document.addEventListener("DOMContentLoaded", () => {
window.LlamaGui.configFlagsUi.renderFlags = () => {
throw new Error("Injected Configure startup failure");
};
}, { once: true });
};
const failingBrowser = {
async newPage() {
const page = await browser.newPage();
page.setDefaultTimeout(5000);
await page.route("**/css/tokens.css*", route => route.abort("connectionreset"));
if (phase === "startup") await page.addInitScript(failStartup);
return page;
},
};
await assert.rejects(
runScenario(failingBrowser, port, async page => {
assert.equal(phase, "reload", "scenario must not run after failed startup");
await page.addInitScript(failStartup);
await page.reload({ waitUntil: "domcontentloaded" });
await page.waitForSelector("#flag-ctx_size", { state: "attached" });
assert.fail("reload must not finish after failed startup");
}),
error => {
assert.equal(error.cause?.name, "TimeoutError");
assert.match(error.message, /#flag-ctx_size/);
assert.match(error.message, /Page errors:\nInjected Configure startup failure/);
assert.match(error.message, /Failed requests:\nGET .*\/css\/tokens\.css.*: net::ERR_CONNECTION_RESET/);
return true;
},
);
});
}

for (const [name, verify = name] of [
["preset imports and safe notifications"],
Expand Down
Loading
Loading