1035139A plucked dulcimer string and its box, as two coupled wave equations on WebGPUJeremy Magland 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 and the microphone is
7 * filling. Any console error, page error or 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 string is in the right place.
11 * Run it after `vite build`: node scripts/smoke.mjs
12 *
13 * `--full` additionally exercises a broken edit, the revert, and playback.
14 * Those need new GPU work started while the page is already drawing, which
15 * headless Chrome cannot always do (see the 2d sibling's smoke script for
16 * the history); a failure from them says as much about the browser as about
17 * the app. What they would have covered on the solver side is covered by
18 * `npm run test:node`, which runs against desktop WebGPU.
19 */
20const withGpuWork = process.argv.includes('--full');
21import { createServer } from 'node:http';
22import { readFile } from 'node:fs/promises';
23import { extname, join } from 'node:path';
24import puppeteer from 'puppeteer-core';
26const DIST = new URL('../dist/', import.meta.url).pathname;
27const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome';
28const MIME = {
29 '.html': 'text/html',
30 '.js': 'text/javascript',
31 '.css': 'text/css',
32 '.json': 'application/json',
33};
35const server = createServer(async (req, res) => {
36 try {
37 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
38 const data = await readFile(join(DIST, path));
39 res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
40 res.end(data);
41 } catch {
42 res.writeHead(404);
43 res.end('not found');
44 }
45});
46await new Promise((r) => server.listen(0, '127.0.0.1', r));
47const port = server.address().port;
49const flagSets = [
50 ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'],
51 [
52 '--headless=new',
53 '--no-sandbox',
54 '--enable-unsafe-webgpu',
55 '--use-webgpu-adapter=swiftshader',
56 '--enable-unsafe-swiftshader',
57 ],
58];
60let ok = false;
61let lastFailure = 'never ran';
62for (const flags of flagSets) {
63 const browser = await puppeteer.launch({
64 executablePath: CHROME,
65 args: [...flags],
66 protocolTimeout: 600_000,
67 });
68 const problems = [];
69 try {
70 const page = await browser.newPage();
71 page.on('console', (m) => {
72 if (m.type() === 'error') problems.push(`console: ${m.text()}`);
73 });
74 page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`));
75 page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`));
77 await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' });
79 // The page loads paused, at the draft grid to keep SwiftShader honest.
80 // Wait for the first compile to have produced an op list.
81 await page.waitForFunction(
82 () => (document.getElementById('compiled')?.textContent ?? '').includes('external'),
83 { timeout: 180_000, polling: 500 },
84 );
85 const errText = () => page.$eval('#err', (n) => n.textContent ?? '');
86 if (await errText()) problems.push(`compile reported: ${await errText()}`);
88 // The compiled plan must contain every coupling op.
89 const compiled = await page.$eval('#compiled', (n) => n.textContent ?? '');
90 for (const op of ['lapw(p, wall)', 'spread(acc)', 'bridge(un)', 'dxxxx(u)']) {
91 if (!compiled.includes(op)) problems.push(`compiled plan is missing ${op}`);
92 }
94 // Drop to the draft grid so a software rasterizer can take steps at all.
95 await page.select('#gridsize', '64');
96 await page.waitForFunction(
97 () => /64×32×32/.test(document.getElementById('domaininfo')?.textContent ?? ''),
98 { timeout: 180_000, polling: 250 },
99 );
101 // A paused page reports no frame rate.
102 if (/ms\/frame/.test(await page.$eval('#stats', (n) => n.textContent ?? ''))) {
103 problems.push('a paused page reported a frame rate');
104 }
105 await page.click('#runpause');
107 /** The stats line reports the step count, so waiting on it says both that
108 * the solver ran and that the frame loop is turning. */
109 const running = async (label) => {
110 await page.waitForFunction(
111 () => {
112 const m = /step ([\d,]+)/.exec(document.getElementById('stats')?.textContent ?? '');
113 return m ? Number(m[1].replace(/,/g, '')) > 50 : false;
114 },
115 { timeout: 180_000, polling: 500 },
116 );
117 console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`);
118 };
119 await running('start');
121 // The microphone must be filling, one sample per timestep.
122 await page.waitForFunction(
123 () => /recorded/.test(document.getElementById('recinfo')?.textContent ?? ''),
124 { timeout: 60_000, polling: 250 },
125 );
126 console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`);
128 // A body edit re-evaluates the scene .m without recompiling: shrinking
129 // the sound hole to nothing must not error and must keep stepping.
130 await page.evaluate(() => {
131 for (const row of document.querySelectorAll('#sceneparams label.slider')) {
132 if (!row.querySelector('span')?.textContent?.startsWith('sound hole radius')) continue;
133 const input = row.querySelector('input');
134 input.value = '0';
135 input.dispatchEvent(new Event('input'));
136 }
137 });
138 await new Promise((r) => setTimeout(r, 1500));
139 if (await errText()) problems.push(`scene edit reported: ${await errText()}`);
140 await running('after body edit');
142 if (!withGpuWork) {
143 console.log(' (skipping the broken-edit and playback checks; pass --full to run them)');
144 } else {
145 // A broken edit must be reported rather than thrown, and must not take
146 // the page down with it.
147 await page.evaluate(() => {
148 const ta = document.getElementById('source');
149 ta.value = ta.value.replace('lapw(p, wall)', 'lapnope(p, wall)');
150 ta.dispatchEvent(new Event('input'));
151 });
152 await page.click('#recompile');
153 await page.waitForFunction(
154 () => (document.getElementById('err')?.textContent ?? '').length > 0,
155 { timeout: 120_000, polling: 250 },
156 );
157 console.log(` bad edit reported: ${(await errText()).split('\n')[0]}`);
159 // And reverting must put it back.
160 await page.click('#revert');
161 await page.waitForFunction(
162 () => (document.getElementById('err')?.textContent ?? '').length === 0,
163 { timeout: 120_000, polling: 250 },
164 );
165 await running('after revert');
167 await page.click('#listen');
168 await new Promise((r) => setTimeout(r, 2000));
169 const listenErr = await errText();
170 if (listenErr) problems.push(`Listen reported: ${listenErr}`);
171 }
173 const report = await page.evaluate(() => ({
174 stats: document.getElementById('stats')?.textContent ?? '',
175 err: document.getElementById('err')?.textContent ?? '',
176 painted: (() => {
177 const canvas = document.getElementById('view');
178 return canvas instanceof HTMLCanvasElement && canvas.width > 0;
179 })(),
180 }));
182 console.log(`flags: ${flags.join(' ')}`);
183 console.log(report.stats.trim());
184 if (report.err) problems.push(`page error box: ${report.err}`);
185 if (!report.painted) problems.push('canvas was never sized');
186 if (problems.length === 0) {
187 ok = true;
188 } else {
189 lastFailure = problems.join('\n');
190 }
191 } catch (e) {
192 lastFailure = [`${e}`, ...problems].join('\n');
193 } finally {
194 await browser.close();
195 }
196 if (ok) break;
197}
199server.close();
200if (!ok) {
201 console.error(`smoke: FAILED\n${lastFailure}`);
202 process.exit(1);
203}
204console.log('smoke: the page runs');
205process.exit(0);