1/**
2 * Does the page actually run in a browser?
3 *
4 * Serves dist/ and opens it in headless Chrome (hardware WebGPU if there is
5 * any, SwiftShader otherwise), then waits for the simulation to report that it
6 * has taken steps and the frame loop to turn. Any console error, page error or
7 * failed request fails the run.
8 *
9 * This checks that the app *works*, not that it looks right — a headless
10 * browser has no opinion about whether the wavefronts are in the right place.
11 * Run it after `vite build`: node scripts/smoke.mjs
12 */
13import { createServer } from 'node:http';
14import { readFile } from 'node:fs/promises';
15import { extname, join } from 'node:path';
16import puppeteer from 'puppeteer-core';
18const DIST = new URL('../dist/', import.meta.url).pathname;
19const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome';
20const MIME = {
21 '.html': 'text/html',
22 '.js': 'text/javascript',
23 '.css': 'text/css',
24 '.json': 'application/json',
25};
27const server = createServer(async (req, res) => {
28 try {
29 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
30 const data = await readFile(join(DIST, path));
31 res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
32 res.end(data);
33 } catch {
34 res.writeHead(404);
35 res.end('not found');
36 }
37});
38await new Promise((r) => server.listen(0, '127.0.0.1', r));
39const port = server.address().port;
41const flagSets = [
42 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
43 [
44 '--headless=new',
45 '--no-sandbox',
46 '--enable-unsafe-webgpu',
47 '--use-webgpu-adapter=swiftshader',
48 '--enable-unsafe-swiftshader',
49 ],
50];
52let ok = false;
53let lastFailure = 'never ran';
54for (const flags of flagSets) {
55 const browser = await puppeteer.launch({
56 executablePath: CHROME,
57 args: [...flags],
58 protocolTimeout: 600_000,
59 });
60 const problems = [];
61 try {
62 const page = await browser.newPage();
63 page.on('console', (m) => {
64 if (m.type() === 'error') problems.push(`console: ${m.text()}`);
65 });
66 page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
67 page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`));
69 await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' });
71 // The page loads paused; the stats line says so once the loop is turning.
72 await page.waitForFunction(
73 () => /paused/.test(document.getElementById('stats')?.textContent ?? ''),
74 { timeout: 120_000, polling: 250 },
75 );
76 await page.click('#runpause');
78 /** The stats line reports the step count, so waiting on it says both that
79 * the solver ran and that the frame loop is turning. */
80 const running = async (label) => {
81 await page.waitForFunction(
82 () => {
83 const m = /step (\d+)/.exec(document.getElementById('stats')?.textContent ?? '');
84 return m ? Number(m[1]) > 20 : false;
85 },
86 { timeout: 180_000, polling: 500 },
87 );
88 console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`);
89 };
90 await running('start');
92 // The timestep slider must reach the solver: halving the CFL fraction
93 // halves dt, which the stats line reports.
94 const dtNow = async () =>
95 Number(/dt = ([0-9.e+-]+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1]);
96 const dtBefore = await dtNow();
97 await page.evaluate(() => {
98 const slider = document.getElementById('cfl');
99 slider.value = '0.25';
100 slider.dispatchEvent(new Event('input'));
101 });
102 await page.waitForFunction(
103 (before) => {
104 const m = /dt = ([0-9.e+-]+)/.exec(document.getElementById('stats')?.textContent ?? '');
105 return m ? Math.abs(Number(m[1]) - before / 2) < before / 20 : false;
106 },
107 { timeout: 30_000, polling: 250 },
108 dtBefore,
109 );
110 console.log(
111 ` timestep: dt ${dtBefore.toExponential(2)} -> ${(await dtNow()).toExponential(2)} at half the CFL`,
112 );
114 // The microphone must be recording, one sample per timestep. (Both
115 // numbers are host-side counters, so this checks the wiring, not the GPU
116 // trace — that would need a readback the page only does on Listen.)
117 await page.waitForFunction(
118 () => /[\d,]+ samples/.test(document.getElementById('recinfo')?.textContent ?? ''),
119 { timeout: 60_000, polling: 250 },
120 );
121 const recorded = Number(
122 /([\d,]+) samples/
123 .exec(await page.$eval('#recinfo', (n) => n.textContent ?? ''))?.[1]
124 .replace(/,/g, ''),
125 );
126 const stepsSoFar = Number(
127 /step (\d+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1],
128 );
129 console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`);
130 if (!(recorded > 0) || recorded > stepsSoFar) {
131 problems.push(`microphone recorded ${recorded} samples in ${stepsSoFar} steps`);
132 }
134 // Swapping the scene rebuilds the medium. The aperture screen is slower
135 // than the background (the smoothing keeps the grid minimum above the
136 // nominal 0.2), so a reported cmin below 1 is the evidence.
137 await page.select('#scene', 'aperture');
138 await page.waitForFunction(
139 () => /c ∈ \[0\./.test(document.getElementById('stats')?.textContent ?? ''),
140 { timeout: 60_000, polling: 250 },
141 );
142 await running('after scene swap');
144 const report = await page.evaluate(() => ({
145 stats: document.getElementById('stats')?.textContent ?? '',
146 err: document.getElementById('err')?.textContent ?? '',
147 painted: (() => {
148 const canvas = document.getElementById('view');
149 return canvas instanceof HTMLCanvasElement && canvas.width > 0;
150 })(),
151 }));
153 console.log(`flags: ${flags.join(' ')}`);
154 console.log(report.stats.trim());
155 if (report.err) problems.push(`page error box: ${report.err}`);
156 if (!report.painted) problems.push('canvas was never sized');
157 if (problems.length === 0) {
158 ok = true;
159 } else {
160 lastFailure = problems.join('\n');
161 }
162 } catch (e) {
163 lastFailure = [`${e}`, ...problems].join('\n');
164 } finally {
165 await browser.close();
166 }
167 if (ok) break;
168}
170server.close();
171if (!ok) {
172 console.error(`smoke: FAILED\n${lastFailure}`);
173 process.exit(1);
174}
175console.log('smoke: the page runs');
176process.exit(0);