1/**
2 * Headless end-to-end check of the built site: serves dist/, loads the
3 * page in headless Chrome, fails on any console error, and exercises one
4 * real in-browser solve through the worker (the Solution section),
5 * asserting the reported error is small. Visual appearance is checked by
6 * a human, not here.
7 *
8 * Usage: npm run build && node scripts/check-app.mjs
9 */
10import { createServer } from "node:http";
11import { readFile } from "node:fs/promises";
12import { join, extname } from "node:path";
13import puppeteer from "puppeteer-core";
15const root = new URL("../dist", import.meta.url).pathname;
16const types = {
17 ".html": "text/html",
18 ".js": "text/javascript",
19 ".css": "text/css",
20 ".wasm": "application/wasm",
21 ".json": "application/json",
22};
24const server = createServer(async (req, res) => {
25 const path = req.url === "/" ? "/index.html" : (req.url ?? "/").split("?")[0];
26 try {
27 const data = await readFile(join(root, path));
28 res.writeHead(200, { "content-type": types[extname(path)] ?? "application/octet-stream" });
29 res.end(data);
30 } catch {
31 res.writeHead(404);
32 res.end("not found");
33 }
34});
35await new Promise((resolve) => server.listen(0, resolve));
36const port = server.address().port;
38const browser = await puppeteer.launch({
39 executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome",
40 args: ["--no-sandbox"],
41});
43const errors = [];
44let failures = 0;
45try {
46 const page = await browser.newPage();
47 page.on("console", (msg) => {
48 if (msg.type() === "error") {
49 const text = msg.text();
50 const url = msg.location()?.url ?? "";
51 // The committed-results fetch may 404 before the results repo has
52 // content; the app reports that in the UI by design.
53 if (url.includes("raw.githubusercontent.com")) return;
54 // numbl logs its linear-algebra bridge choice at error level.
55 if (text.includes("using bridge:")) return;
56 errors.push(`${text} (${url})`);
57 }
58 });
59 page.on("pageerror", (err) => errors.push(String(err)));
61 await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "networkidle2" });
62 await page.waitForSelector("h1");
64 // The empty-state or chart should be present.
65 const hasChartArea = await page.evaluate(
66 () => document.body.innerText.includes("Work-precision results")
67 );
68 if (!hasChartArea) {
69 console.error("FAIL: work-precision section missing");
70 failures++;
71 }
73 // One real solve through the worker: the Solution section's compute
74 // button, default solver (mfs) at its default n.
75 const buttons = await page.$$("button");
76 let computeBtn = null;
77 for (const b of buttons) {
78 const t = await b.evaluate((el) => el.textContent);
79 if (t && t.includes("Compute in this browser")) computeBtn = b;
80 }
81 if (!computeBtn) {
82 console.error("FAIL: compute button not found");
83 failures++;
84 } else {
85 await computeBtn.click();
86 await page.waitForFunction(
87 () => document.body.innerText.includes("rel max error"),
88 { timeout: 120000 }
89 );
90 const text = await page.evaluate(() => document.body.innerText);
91 const m = text.match(/rel max error ([0-9.]+e[+-][0-9]+)/);
92 if (!m) {
93 console.error("FAIL: no reported error after compute");
94 failures++;
95 } else {
96 const err = parseFloat(m[1]);
97 // mfs at its default n on star-medium should be far below 1e-6.
98 if (!(err < 1e-6)) {
99 console.error(`FAIL: in-browser mfs error ${err} not < 1e-6`);
100 failures++;
101 } else {
102 console.log(`in-browser solve ok (rel max error ${m[1]})`);
103 }
104 }
105 }
107 if (errors.length > 0) {
108 console.error("FAIL: console errors:");
109 for (const e of errors) console.error(` ${e}`);
110 failures++;
111 }
112} finally {
113 await browser.close();
114 server.close();
115}
117if (failures > 0) {
118 console.error(`${failures} failure(s)`);
119 process.exit(1);
120}
121console.log("check-app: all checks passed");