/** * 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 the frame loop to turn. 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 */ 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; the stats line says so once the loop is turning. await page.waitForFunction( () => /paused/.test(document.getElementById('stats')?.textContent ?? ''), { timeout: 120_000, polling: 250 }, ); 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()}`); }; 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`, ); // The microphone must be recording, one sample per timestep. (Both // numbers are host-side counters, so this checks the wiring, not the GPU // trace — that would need a readback the page only does on Listen.) await page.waitForFunction( () => /[\d,]+ samples/.test(document.getElementById('recinfo')?.textContent ?? ''), { timeout: 60_000, polling: 250 }, ); const recorded = Number( /([\d,]+) samples/ .exec(await page.$eval('#recinfo', (n) => n.textContent ?? ''))?.[1] .replace(/,/g, ''), ); const stepsSoFar = Number( /step (\d+)/.exec(await page.$eval('#stats', (n) => n.textContent ?? ''))?.[1], ); console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`); if (!(recorded > 0) || recorded > stepsSoFar) { problems.push(`microphone recorded ${recorded} samples in ${stepsSoFar} steps`); } // Swapping the scene rebuilds the medium. The aperture screen is slower // than the background (the smoothing keeps the grid minimum above the // nominal 0.2), so a reported cmin below 1 is the evidence. await page.select('#scene', 'aperture'); await page.waitForFunction( () => /c ∈ \[0\./.test(document.getElementById('stats')?.textContent ?? ''), { timeout: 60_000, polling: 250 }, ); await running('after scene swap'); const report = await page.evaluate(() => ({ stats: document.getElementById('stats')?.textContent ?? '', err: document.getElementById('err')?.textContent ?? '', painted: (() => { const canvas = document.getElementById('view'); return canvas instanceof HTMLCanvasElement && canvas.width > 0; })(), })); console.log(`flags: ${flags.join(' ')}`); console.log(report.stats.trim()); 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);