/** * 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 compile to finish, starts * the run, and checks that steps are being taken and the microphone is * filling. 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 string is in the right place. * Run it after `vite build`: node scripts/smoke.mjs * * `--full` additionally exercises a broken edit, the revert, and playback. * Those need new GPU work started while the page is already drawing, which * headless Chrome cannot always do (see the 2d sibling's smoke script for * the history); 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. */ 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, at the draft grid to keep SwiftShader honest. // Wait for the first compile to have produced an op list. await page.waitForFunction( () => (document.getElementById('compiled')?.textContent ?? '').includes('external'), { timeout: 180_000, polling: 500 }, ); const errText = () => page.$eval('#err', (n) => n.textContent ?? ''); if (await errText()) problems.push(`compile reported: ${await errText()}`); // The compiled plan must contain every coupling op. const compiled = await page.$eval('#compiled', (n) => n.textContent ?? ''); for (const op of ['lapw(p, wall)', 'spread(acc)', 'bridge(un)', 'dxxxx(u)']) { if (!compiled.includes(op)) problems.push(`compiled plan is missing ${op}`); } // Drop to the draft grid so a software rasterizer can take steps at all. await page.select('#gridsize', '64'); await page.waitForFunction( () => /64×32×32/.test(document.getElementById('domaininfo')?.textContent ?? ''), { timeout: 180_000, polling: 250 }, ); // A paused page reports no frame rate. 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].replace(/,/g, '')) > 50 : false; }, { timeout: 180_000, polling: 500 }, ); console.log(` ${label}: ${(await page.$eval('#stats', (n) => n.textContent)).trim()}`); }; await running('start'); // The microphone must be filling, one sample per timestep. await page.waitForFunction( () => /recorded/.test(document.getElementById('recinfo')?.textContent ?? ''), { timeout: 60_000, polling: 250 }, ); console.log(` microphone: ${(await page.$eval('#recinfo', (n) => n.textContent ?? '')).trim()}`); // A body edit re-evaluates the scene .m without recompiling: shrinking // the sound hole to nothing must not error and must keep stepping. await page.evaluate(() => { for (const row of document.querySelectorAll('#sceneparams label.slider')) { if (!row.querySelector('span')?.textContent?.startsWith('sound hole radius')) continue; const input = row.querySelector('input'); input.value = '0'; input.dispatchEvent(new Event('input')); } }); await new Promise((r) => setTimeout(r, 1500)); if (await errText()) problems.push(`scene edit reported: ${await errText()}`); await running('after body edit'); if (!withGpuWork) { console.log(' (skipping the broken-edit and playback checks; pass --full to run them)'); } else { // 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('lapw(p, wall)', 'lapnope(p, wall)'); 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'); await page.click('#listen'); await new Promise((r) => setTimeout(r, 2000)); const listenErr = await errText(); if (listenErr) problems.push(`Listen reported: ${listenErr}`); } 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);