/ concept-collection / acoustic-scattering-2d
Sign in
concept-collection / acoustic-scattering-2d
acoustic-scattering-2d / scripts / smoke.mjs
258 lines · 10.0 KBCodeBlameHistory
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 drawn frames. Any console error, page error or failed
7 * 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 *
13 * `--full` additionally swaps the model, edits the source, and plays back the
14 * microphone. All three need new GPU work started while the page is already
15 * drawing — a pipeline, a buffer mapping — and headless Chrome cannot always
16 * do that: on some machines everything of the sort fails with "A valid
17 * external Instance reference no longer exists", in a thirty-line WebGPU page
18 * with none of this project in it. So those steps are opt-in, and a failure
19 * from them says as much about the browser as about the app. What they would
20 * have covered on the solver side is covered by `npm run test:node`, which
21 * runs against desktop WebGPU where readback works.
22 */
23const withGpuWork = process.argv.includes('--full');
24import { createServer } from 'node:http';
25import { readFile } from 'node:fs/promises';
26import { extname, join } from 'node:path';
27import puppeteer from 'puppeteer-core';
29const DIST = new URL('../dist/', import.meta.url).pathname;
30const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome';
31const MIME = {
32 '.html': 'text/html',
33 '.js': 'text/javascript',
34 '.css': 'text/css',
35 '.json': 'application/json',
36};
38const server = createServer(async (req, res) => {
39 try {
40 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
41 const data = await readFile(join(DIST, path));
42 res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
43 res.end(data);
44 } catch {
45 res.writeHead(404);
46 res.end('not found');
47 }
48});
49await new Promise((r) => server.listen(0, '127.0.0.1', r));
50const port = server.address().port;
52const flagSets = [
53 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
54 [
55 '--headless=new',
56 '--no-sandbox',
57 '--enable-unsafe-webgpu',
58 '--use-webgpu-adapter=swiftshader',
59 '--enable-unsafe-swiftshader',
60 ],
61];
63let ok = false;
64let lastFailure = 'never ran';
65for (const flags of flagSets) {
66 const browser = await puppeteer.launch({
67 executablePath: CHROME,
68 args: [...flags],
69 protocolTimeout: 600_000,
70 });
71 const problems = [];
72 try {
73 const page = await browser.newPage();
74 page.on('console', (m) => {
75 if (m.type() === 'error') problems.push(`console: ${m.text()}`);
76 });
77 page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
78 page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`));
80 await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' });
82 // The page loads paused. Wait for the first compile to have produced an op
83 // list, then start it.
84 await page.waitForFunction(
85 () => (document.getElementById('compiled')?.textContent ?? '').includes('stencil'),
86 { timeout: 180_000, polling: 500 },
87 );
88 // A paused page reports no frame rate, because it takes no steps and draws
89 // nothing.
90 await page.waitForFunction(
91 () => /paused/.test(document.getElementById('stats')?.textContent ?? ''),
92 { timeout: 60_000, polling: 250 },
93 );
94 if (/ms\/frame/.test(await page.$eval('#stats', (n) => n.textContent ?? ''))) {
95 problems.push('a paused page reported a frame rate');
96 }
97 await page.click('#runpause');
99 /** The stats line reports the step count, so waiting on it says both that
100 * the solver ran and that the frame loop is turning. */
101 const running = async (label) => {
102 await page.waitForFunction(
103 () => {
104 const m = /step (\d+)/.exec(document.getElementById('stats')?.textContent ?? '');
105 return m ? Number(m[1]) > 20 : false;
106 },
107 { timeout: 180_000, polling: 500 },
108 );
109 console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`);
110 };
111 const compiled = () => page.$eval('#compiled', (n) => n.textContent ?? '');
112 const errText = () => page.$eval('#err', (n) => n.textContent ?? '');
114 await running('start');
116 // The timestep slider must reach the solver: halving the CFL fraction
117 // halves dt, which the stats line reports.
118 const dtNow = async () =>
119 Number(/dt = ([0-9.e+-]+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1]);
120 const dtBefore = await dtNow();
121 await page.evaluate(() => {
122 const slider = document.getElementById('cfl');
123 slider.value = '0.25';
124 slider.dispatchEvent(new Event('input'));
125 });
126 await page.waitForFunction(
127 (before) => {
128 const m = /dt = ([0-9.e+-]+)/.exec(document.getElementById('stats')?.textContent ?? '');
129 return m ? Math.abs(Number(m[1]) - before / 2) < before / 20 : false;
130 },
131 { timeout: 30_000, polling: 250 },
132 dtBefore,
133 );
134 console.log(` timestep: dt ${dtBefore.toExponential(2)} -> ${(await dtNow()).toExponential(2)} at half the CFL`);
136 // Clicking the picture must put the microphone there. The canvas shows
137 // the whole domain, so the centre of it is the origin.
138 const micReadout = () =>
139 page.$$eval('#micparams output', (nodes) => nodes.map((n) => Number(n.textContent)));
140 const box = await page.$eval('#view', (n) => {
141 const r = n.getBoundingClientRect();
142 return { x: r.x, y: r.y, w: r.width, h: r.height };
143 });
144 await page.mouse.click(box.x + box.w / 2, box.y + box.h / 2);
145 await new Promise((r) => setTimeout(r, 300));
146 const [mx, my] = await micReadout();
147 console.log(` microphone dragged to (${mx}, ${my}) by clicking the centre`);
148 if (Math.abs(mx) > 0.05 || Math.abs(my) > 0.05) {
149 problems.push(`clicking the centre put the microphone at (${mx}, ${my})`);
150 }
152 // The microphone must be recording, one sample per timestep, and its
153 // trace must come back off the GPU when asked for.
154 await page.waitForFunction(
155 () => /(\d[\d,]*) samples/.test(document.getElementById('recinfo')?.textContent ?? ''),
156 { timeout: 60_000, polling: 250 },
157 );
158 console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`);
159 const steps = Number(
160 /step (\d+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1],
161 );
162 const recorded = Number(
163 /([\d,]+) samples/.exec(await page.$eval('#recinfo', (n) => n.textContent ?? ''))?.[1]
164 .replace(/,/g, ''),
165 );
166 if (!(recorded > 0) || recorded > steps) {
167 problems.push(`microphone recorded ${recorded} samples in ${steps} steps`);
168 }
169 if (withGpuWork) {
170 await page.click('#listen');
171 await new Promise((r) => setTimeout(r, 2000));
172 const listenErr = await errText();
173 if (listenErr) problems.push(`Listen reported: ${listenErr}`);
174 }
176 // Swapping the scene re-evaluates its .m and re-uploads the medium. The
177 // two-slit screen is much faster than the background, so the reported
178 // speed range is the evidence.
179 await page.select('#scene', 'slit');
180 await page.waitForFunction(
181 () => /c ∈ \[1, 3\.97\]/.test(document.getElementById('stats')?.textContent ?? ''),
182 { timeout: 60_000, polling: 250 },
183 );
184 await running('after scene swap');
186 if (!withGpuWork) {
187 console.log(' (skipping the recompile and playback checks; pass --full to run them)');
188 } else {
189 // Swapping the model recompiles, and the fourth-order one must reach for
190 // the other stencil.
191 await page.select('#model', 'leapfrog4');
192 await page.waitForFunction(
193 () => (document.getElementById('compiled')?.textContent ?? '').includes('lap4'),
194 { timeout: 120_000, polling: 250 },
195 );
196 await running('after model swap');
198 // A broken edit must be reported rather than thrown, and must not take
199 // the page down with it.
200 await page.evaluate(() => {
201 const ta = document.getElementById('source');
202 ta.value = ta.value.replace('lap4(p)', 'lap9(p)');
203 ta.dispatchEvent(new Event('input'));
204 });
205 await page.click('#recompile');
206 await page.waitForFunction(
207 () => (document.getElementById('err')?.textContent ?? '').length > 0,
208 { timeout: 120_000, polling: 250 },
209 );
210 console.log(` bad edit reported: ${(await errText()).split('\n')[0]}`);
212 // And reverting must put it back.
213 await page.click('#revert');
214 await page.waitForFunction(
215 () => (document.getElementById('err')?.textContent ?? '').length === 0,
216 { timeout: 120_000, polling: 250 },
217 );
218 await running('after revert');
219 if ((await compiled()).includes('lap4') === false) {
220 problems.push('the reverted model did not recompile');
221 }
222 }
223 const report = await page.evaluate(() => ({
224 stats: document.getElementById('stats')?.textContent ?? '',
225 err: document.getElementById('err')?.textContent ?? '',
226 compiled: document.getElementById('compiled')?.textContent ?? '',
227 // The canvas must have painted something other than the clear colour.
228 painted: (() => {
229 const canvas = document.getElementById('view');
230 return canvas instanceof HTMLCanvasElement && canvas.width > 0;
231 })(),
232 }));
234 console.log(`flags: ${flags.join(' ')}`);
235 console.log(report.stats.trim());
236 console.log(report.compiled.split('\n').slice(0, 20).join('\n'));
237 if (report.err) problems.push(`page error box: ${report.err}`);
238 if (!report.painted) problems.push('canvas was never sized');
239 if (problems.length === 0) {
240 ok = true;
241 } else {
242 lastFailure = problems.join('\n');
243 }
244 } catch (e) {
245 lastFailure = [`${e}`, ...problems].join('\n');
246 } finally {
247 await browser.close();
248 }
249 if (ok) break;
252server.close();
253if (!ok) {
254 console.error(`smoke: FAILED\n${lastFailure}`);
255 process.exit(1);
257console.log('smoke: the page runs');
258process.exit(0);
moveopenescclose