/** * Does the page actually run in a browser? * * Serves dist/ and opens it in headless Chrome (hardware WebGPU if there is * any, SwiftShader otherwise), then waits for the simulation to report that it * has taken steps and drawn frames. Any console error, page error or failed * request fails the run. * * This checks that the app *works*, not that it looks right — a headless * browser has no opinion about whether the wavefronts are in the right place. * Run it after `vite build`: node scripts/smoke.mjs * * `--full` additionally swaps the model, edits the source, and plays back the * microphone. All three need new GPU work started while the page is already * drawing — a pipeline, a buffer mapping — and headless Chrome cannot always * do that: on some machines everything of the sort fails with "A valid * external Instance reference no longer exists", in a thirty-line WebGPU page * with none of this project in it. So those steps are opt-in, and a failure * from them says as much about the browser as about the app. What they would * have covered on the solver side is covered by `npm run test:node`, which * runs against desktop WebGPU where readback works. */ const withGpuWork = process.argv.includes('--full'); import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import { extname, join } from 'node:path'; import puppeteer from 'puppeteer-core'; const DIST = new URL('../dist/', import.meta.url).pathname; const CHROME = process.env.CHROME_PATH ?? '/usr/bin/google-chrome'; const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', }; const server = createServer(async (req, res) => { try { const path = req.url === '/' ? '/index.html' : req.url.split('?')[0]; const data = await readFile(join(DIST, path)); res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' }); res.end(data); } catch { res.writeHead(404); res.end('not found'); } }); await new Promise((r) => server.listen(0, '127.0.0.1', r)); const port = server.address().port; const flagSets = [ ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--enable-features=Vulkan'], [ '--headless=new', '--no-sandbox', '--enable-unsafe-webgpu', '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader', ], ]; let ok = false; let lastFailure = 'never ran'; for (const flags of flagSets) { const browser = await puppeteer.launch({ executablePath: CHROME, args: [...flags], protocolTimeout: 600_000, }); const problems = []; try { const page = await browser.newPage(); page.on('console', (m) => { if (m.type() === 'error') problems.push(`console: ${m.text()}`); }); page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)); page.on('requestfailed', (r) => problems.push(`request failed: ${r.url()}`)); await page.goto(`http://127.0.0.1:${port}/index.html`, { waitUntil: 'load' }); // The page loads paused. Wait for the first compile to have produced an op // list, then start it. await page.waitForFunction( () => (document.getElementById('compiled')?.textContent ?? '').includes('stencil'), { timeout: 180_000, polling: 500 }, ); // A paused page reports no frame rate, because it takes no steps and draws // nothing. await page.waitForFunction( () => /paused/.test(document.getElementById('stats')?.textContent ?? ''), { timeout: 60_000, polling: 250 }, ); if (/ms\/frame/.test(await page.$eval('#stats', (n) => n.textContent ?? ''))) { problems.push('a paused page reported a frame rate'); } await page.click('#runpause'); /** The stats line reports the step count, so waiting on it says both that * the solver ran and that the frame loop is turning. */ const running = async (label) => { await page.waitForFunction( () => { const m = /step (\d+)/.exec(document.getElementById('stats')?.textContent ?? ''); return m ? Number(m[1]) > 20 : false; }, { timeout: 180_000, polling: 500 }, ); console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`); }; const compiled = () => page.$eval('#compiled', (n) => n.textContent ?? ''); const errText = () => page.$eval('#err', (n) => n.textContent ?? ''); await running('start'); // The timestep slider must reach the solver: halving the CFL fraction // halves dt, which the stats line reports. const dtNow = async () => Number(/dt = ([0-9.e+-]+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1]); const dtBefore = await dtNow(); await page.evaluate(() => { const slider = document.getElementById('cfl'); slider.value = '0.25'; slider.dispatchEvent(new Event('input')); }); await page.waitForFunction( (before) => { const m = /dt = ([0-9.e+-]+)/.exec(document.getElementById('stats')?.textContent ?? ''); return m ? Math.abs(Number(m[1]) - before / 2) < before / 20 : false; }, { timeout: 30_000, polling: 250 }, dtBefore, ); console.log(` timestep: dt ${dtBefore.toExponential(2)} -> ${(await dtNow()).toExponential(2)} at half the CFL`); // Clicking the picture must put the microphone there. The canvas shows // the whole domain, so the centre of it is the origin. const micReadout = () => page.$$eval('#micparams output', (nodes) => nodes.map((n) => Number(n.textContent))); const box = await page.$eval('#view', (n) => { const r = n.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; }); await page.mouse.click(box.x + box.w / 2, box.y + box.h / 2); await new Promise((r) => setTimeout(r, 300)); const [mx, my] = await micReadout(); console.log(` microphone dragged to (${mx}, ${my}) by clicking the centre`); if (Math.abs(mx) > 0.05 || Math.abs(my) > 0.05) { problems.push(`clicking the centre put the microphone at (${mx}, ${my})`); } // The microphone must be recording, one sample per timestep, and its // trace must come back off the GPU when asked for. await page.waitForFunction( () => /(\d[\d,]*) samples/.test(document.getElementById('recinfo')?.textContent ?? ''), { timeout: 60_000, polling: 250 }, ); console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`); const steps = Number( /step (\d+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1], ); const recorded = Number( /([\d,]+) samples/.exec(await page.$eval('#recinfo', (n) => n.textContent ?? ''))?.[1] .replace(/,/g, ''), ); if (!(recorded > 0) || recorded > steps) { problems.push(`microphone recorded ${recorded} samples in ${steps} steps`); } if (withGpuWork) { await page.click('#listen'); await new Promise((r) => setTimeout(r, 2000)); const listenErr = await errText(); if (listenErr) problems.push(`Listen reported: ${listenErr}`); } // Swapping the scene re-evaluates its .m and re-uploads the medium. The // two-slit screen is much faster than the background, so the reported // speed range is the evidence. await page.select('#scene', 'slit'); await page.waitForFunction( () => /c ∈ \[1, 3\.97\]/.test(document.getElementById('stats')?.textContent ?? ''), { timeout: 60_000, polling: 250 }, ); await running('after scene swap'); if (!withGpuWork) { console.log(' (skipping the recompile and playback checks; pass --full to run them)'); } else { // Swapping the model recompiles, and the fourth-order one must reach for // the other stencil. await page.select('#model', 'leapfrog4'); await page.waitForFunction( () => (document.getElementById('compiled')?.textContent ?? '').includes('lap4'), { timeout: 120_000, polling: 250 }, ); await running('after model swap'); // A broken edit must be reported rather than thrown, and must not take // the page down with it. await page.evaluate(() => { const ta = document.getElementById('source'); ta.value = ta.value.replace('lap4(p)', 'lap9(p)'); ta.dispatchEvent(new Event('input')); }); await page.click('#recompile'); await page.waitForFunction( () => (document.getElementById('err')?.textContent ?? '').length > 0, { timeout: 120_000, polling: 250 }, ); console.log(` bad edit reported: ${(await errText()).split('\n')[0]}`); // And reverting must put it back. await page.click('#revert'); await page.waitForFunction( () => (document.getElementById('err')?.textContent ?? '').length === 0, { timeout: 120_000, polling: 250 }, ); await running('after revert'); if ((await compiled()).includes('lap4') === false) { problems.push('the reverted model did not recompile'); } } const report = await page.evaluate(() => ({ stats: document.getElementById('stats')?.textContent ?? '', err: document.getElementById('err')?.textContent ?? '', compiled: document.getElementById('compiled')?.textContent ?? '', // The canvas must have painted something other than the clear colour. painted: (() => { const canvas = document.getElementById('view'); return canvas instanceof HTMLCanvasElement && canvas.width > 0; })(), })); console.log(`flags: ${flags.join(' ')}`); console.log(report.stats.trim()); console.log(report.compiled.split('\n').slice(0, 20).join('\n')); if (report.err) problems.push(`page error box: ${report.err}`); if (!report.painted) problems.push('canvas was never sized'); if (problems.length === 0) { ok = true; } else { lastFailure = problems.join('\n'); } } catch (e) { lastFailure = [`${e}`, ...problems].join('\n'); } finally { await browser.close(); } if (ok) break; } server.close(); if (!ok) { console.error(`smoke: FAILED\n${lastFailure}`); process.exit(1); } console.log('smoke: the page runs'); process.exit(0);