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 compile to finish, starts
6 * the run, and checks that steps are being taken, the microphone is filling,
7 * a body edit survives, and switching to another preset recompiles cleanly.
8 * Any console error, page error or failed request fails the run.
9 *
10 * This checks that the app *works*, not that it looks right — a headless
11 * browser has no opinion about whether the drum is in the right place. The
12 * solver physics is covered by `npm run test:node` against desktop WebGPU.
13 * Run it after `vite build`: node scripts/smoke.mjs
14 */
15import { createServer } from 'node:http';
16import { readFile } from 'node:fs/promises';
17import { extname, join } from 'node:path';
18import puppeteer from 'puppeteer-core';
20const DIST = new URL('../dist/', import.meta.url).pathname;
21const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome';
22const MIME = {
23 '.html': 'text/html',
24 '.js': 'text/javascript',
25 '.css': 'text/css',
26 '.json': 'application/json',
27};
29const server = createServer(async (req, res) => {
30 try {
31 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
32 const data = await readFile(join(DIST, path));
33 res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
34 res.end(data);
35 } catch {
36 res.writeHead(404);
37 res.end('not found');
38 }
39});
40await new Promise((r) => server.listen(0, '127.0.0.1', r));
41const port = server.address().port;
43const flagSets = [
44 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
45 [
46 '--headless=new',
47 '--no-sandbox',
48 '--enable-unsafe-webgpu',
49 '--use-webgpu-adapter=swiftshader',
50 '--enable-unsafe-swiftshader',
51 ],
52];
54let ok = false;
55let lastFailure = 'never ran';
56for (const flags of flagSets) {
57 const browser = await puppeteer.launch({
58 executablePath: CHROME,
59 args: [...flags],
60 protocolTimeout: 600_000,
61 });
62 const problems = [];
63 try {
64 const page = await browser.newPage();
65 page.on('console', (m) => {
66 if (m.type() === 'error') problems.push(`console: ${m.text()}`);
67 });
68 page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
69 page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`));
71 await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' });
73 // The page loads paused. Wait for the first compile's op list.
74 await page.waitForFunction(
75 () => (document.getElementById('compiled')?.textContent ?? '').includes('external'),
76 { timeout: 180_000, polling: 500 },
77 );
78 const errText = () => page.$eval('#err', (n) => n.textContent ?? '');
79 if (await errText()) problems.push(`compile reported: ${await errText()}`);
81 // The default (dulcimer) scene's plan must carry its coupling ops.
82 const compiled = await page.$eval('#compiled', (n) => n.textContent ?? '');
83 for (const op of ['lapw(p, wall)', 'rad_s1(acc_s1)', 'brg_s1(un_s1)', 'dxxxx_s1(u_s1)']) {
84 if (!compiled.includes(op)) problems.push(`compiled plan is missing ${op}`);
85 }
87 // Drop to the draft grid so a software rasterizer can take steps at all.
88 await page.click('#tab-build');
89 await page.select('#gridsize', '64');
90 await page.waitForFunction(
91 () => /64×32×32/.test(document.getElementById('domaininfo')?.textContent ?? ''),
92 { timeout: 180_000, polling: 250 },
93 );
95 // A paused page reports no frame rate.
96 if (/ms\/frame/.test(await page.$eval('#stats', (n) => n.textContent ?? ''))) {
97 problems.push('a paused page reported a frame rate');
98 }
99 await page.click('#runpause');
101 /** The stats line reports the step count, so waiting on it says both that
102 * the solver ran and that the frame loop is turning. */
103 const running = async (label) => {
104 await page.waitForFunction(
105 () => {
106 const m = /step ([\d,]+)/.exec(document.getElementById('stats')?.textContent ?? '');
107 return m ? Number(m[1].replace(/,/g, '')) > 50 : false;
108 },
109 { timeout: 180_000, polling: 500 },
110 );
111 console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`);
112 };
113 await running('start');
115 // The microphone must be filling, one sample per timestep.
116 await page.waitForFunction(
117 () => /recorded/.test(document.getElementById('recinfo')?.textContent ?? ''),
118 { timeout: 60_000, polling: 250 },
119 );
120 console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`);
122 // A body edit re-evaluates the scene .m without recompiling: shrinking
123 // the sound hole to nothing must not error and must keep stepping.
124 await page.evaluate(() => {
125 for (const row of document.querySelectorAll('#bodyparams label.slider')) {
126 if (!row.querySelector('span')?.textContent?.startsWith('sound hole radius')) continue;
127 const input = row.querySelector('input');
128 input.value = '0';
129 input.dispatchEvent(new Event('input'));
130 }
131 });
132 await new Promise((r) => setTimeout(r, 1500));
133 if (await errText()) problems.push(`scene edit reported: ${await errText()}`);
134 await running('after body edit');
136 // Switching preset rebuilds a different scene (a struck bar, no body).
137 await page.click('#tab-play');
138 await page.evaluate(() => {
139 for (const card of document.querySelectorAll('#presets .preset')) {
140 if (card.querySelector('b')?.textContent === 'Chime') card.click();
141 }
142 });
143 await page.waitForFunction(
144 () => (document.getElementById('compiled')?.textContent ?? '').includes('dxxxx_b1'),
145 { timeout: 180_000, polling: 500 },
146 );
147 if (await errText()) problems.push(`preset switch reported: ${await errText()}`);
148 await running('after preset switch');
150 const report = await page.evaluate(() => ({
151 stats: document.getElementById('stats')?.textContent ?? '',
152 err: document.getElementById('err')?.textContent ?? '',
153 painted: (() => {
154 const canvas = document.getElementById('view');
155 return canvas instanceof HTMLCanvasElement && canvas.width > 0;
156 })(),
157 }));
159 console.log(`flags: ${flags.join(' ')}`);
160 console.log(report.stats.trim());
161 if (report.err) problems.push(`page error box: ${report.err}`);
162 if (!report.painted) problems.push('canvas was never sized');
163 if (problems.length === 0) {
164 ok = true;
165 } else {
166 lastFailure = problems.join('\n');
167 }
168 } catch (e) {
169 lastFailure = [`${e}`, ...problems].join('\n');
170 } finally {
171 await browser.close();
172 }
173 if (ok) break;
174}
176server.close();
177if (!ok) {
178 console.error(`smoke: FAILED\n${lastFailure}`);
179 process.exit(1);
180}
181console.log('smoke: the page runs');
182process.exit(0);