#!/usr/bin/env node // Drives the built app in a headless browser: every chapter is opened, every // lab is run, and the page is checked for console errors and for figures that // actually contain data. // // npm run build && node scripts/browser-test.mjs [--shots] [--only ] // // --shots writes a full-page screenshot per chapter to tmp/, which is the only // way to judge layout; the assertions here cover behaviour, not appearance. import { spawn } from 'node:child_process' import { mkdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import puppeteer from 'puppeteer' const root = join(dirname(fileURLToPath(import.meta.url)), '..') const PORT = 5197 const URL = `http://localhost:${PORT}/` const SHOTS = process.argv.includes('--shots') const ONLY = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : null let failures = 0 const check = (name, ok, detail = '') => { console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ` ${detail}` : ''}`) if (!ok) failures++ } const server = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort'], { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], detached: true, }) let stopped = false const stop = () => { if (stopped) return stopped = true try { process.kill(-server.pid, 'SIGKILL') } catch { /* already gone */ } } process.on('exit', stop) process.on('SIGINT', () => { stop() process.exit(130) }) { const deadline = Date.now() + 30000 let up = false while (Date.now() < deadline) { try { const res = await fetch(URL) if (res.ok) { up = true break } } catch { /* not listening yet */ } await new Promise((r) => setTimeout(r, 250)) } if (!up) { stop() throw new Error(`vite preview never answered on ${URL}`) } } mkdirSync(join(root, 'tmp'), { recursive: true }) const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }) const page = await browser.newPage() await page.setViewport({ width: 1400, height: 1100 }) const problems = [] // numbl announces which LAPACK bridge it picked on console.error; that is // information, not a fault const BENIGN = /using bridge:/ page.on('console', (m) => { if (m.type() === 'error' && !BENIGN.test(m.text())) problems.push(`console.error: ${m.text()}`) }) page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`)) page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()}`)) const settle = (timeout = 240000) => page.waitForFunction( () => { const labs = [...document.querySelectorAll('.lab')] if (labs.length === 0) return document.querySelector('.chapter') !== null return labs.every((l) => { const busy = [...l.querySelectorAll('button')].some((b) => /running/.test(b.textContent)) return !busy && !l.querySelector('.lab-placeholder') }) }, { timeout, polling: 400 }, ) try { await page.goto(URL, { waitUntil: 'networkidle2' }) const chapters = await page.evaluate(() => [...document.querySelectorAll('.nav-item')].map((b) => b.textContent.replace(/^\d+/, '').trim()), ) check('the sidebar lists chapters', chapters.length > 1, `${chapters.length} entries`) for (let i = 0; i < chapters.length; i++) { const name = chapters[i] if (ONLY && !name.toLowerCase().includes(ONLY.toLowerCase())) continue console.log(`\n${name}`) await page.evaluate((k) => { document.querySelectorAll('.nav-item')[k].click() }, i) await new Promise((r) => setTimeout(r, 300)) // labs that wait to be asked: ask them await page.evaluate(() => { for (const lab of document.querySelectorAll('.lab')) { if (!lab.querySelector('.lab-placeholder')) continue const b = [...lab.querySelectorAll('button')].find((x) => /^run$/.test(x.textContent.trim())) b?.click() } }) await new Promise((r) => setTimeout(r, 300)) try { await settle() } catch { check(`${name}: labs finish`, false, 'timed out') continue } const info = await page.evaluate(() => { const labs = [...document.querySelectorAll('.lab')] return { labs: labs.length, errors: [...document.querySelectorAll('.lab-error')].map((e) => e.textContent.slice(0, 300)), drawn: [...document.querySelectorAll('.lab-figures svg path')].filter( (p) => (p.getAttribute('d') ?? '').length > 12, ).length, canvases: document.querySelectorAll('.lab-figures canvas').length, stats: [...document.querySelectorAll('.stat-value')].map((s) => s.textContent), } }) check(`${name}: no MATLAB error`, info.errors.length === 0, info.errors.join(' | ')) if (info.labs > 0) { check( `${name}: figures carry data`, info.drawn + info.canvases > 0, `${info.drawn} paths, ${info.canvases} canvases`, ) } if (info.labs > 0) console.log(` ${info.stats.join(' ')}`) if (SHOTS) { const file = join(root, 'tmp', `ch-${String(i + 1).padStart(2, '0')}.png`) await page.screenshot({ path: file, fullPage: true }) console.log(` shot: ${file}`) } } check('no console errors', problems.length === 0, problems.slice(0, 4).join(' | ')) } finally { await browser.close() stop() } console.log(failures === 0 ? '\nall checks passed' : `\n${failures} check(s) failed`) process.exit(failures === 0 ? 0 : 1)