/ tutorials / wave-acoustics
Sign in
tutorials / wave-acoustics
wave-acoustics / scripts / browser-test.mjs
167 lines · 5.6 KBBlameHistoryRaw
1#!/usr/bin/env node
2// Drives the built app in a headless browser: every chapter is opened, every
3// lab is run, and the page is checked for console errors and for figures that
4// actually contain data.
5//
6// Several labs here are deliberately heavy -- a dimension sweep, a pair of
7// MCMC chains -- so the settle timeout is generous and a full pass takes a few
8// minutes.
9//
10// npm run build && node scripts/browser-test.mjs [--shots] [--only <id>]
11//
12// --shots writes a full-page screenshot per chapter to tmp/, which is the only
13// way to judge layout; the assertions here cover behaviour, not appearance.
14import { spawn } from 'node:child_process'
15import { mkdirSync } from 'node:fs'
16import { dirname, join } from 'node:path'
17import { fileURLToPath } from 'node:url'
18import puppeteer from 'puppeteer'
20const root = join(dirname(fileURLToPath(import.meta.url)), '..')
21const PORT = 5198
22const URL = `http://localhost:${PORT}/`
23const SHOTS = process.argv.includes('--shots')
24const ONLY = process.argv.includes('--only') ? process.argv[process.argv.indexOf('--only') + 1] : null
26let failures = 0
27const check = (name, ok, detail = '') => {
28 console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ` ${detail}` : ''}`)
29 if (!ok) failures++
32const server = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort'], {
33 cwd: root,
34 stdio: ['ignore', 'pipe', 'pipe'],
35 detached: true,
36})
37let stopped = false
38const stop = () => {
39 if (stopped) return
40 stopped = true
41 try {
42 process.kill(-server.pid, 'SIGKILL')
43 } catch {
44 /* already gone */
45 }
47process.on('exit', stop)
48process.on('SIGINT', () => {
49 stop()
50 process.exit(130)
51})
54 const deadline = Date.now() + 30000
55 let up = false
56 while (Date.now() < deadline) {
57 try {
58 const res = await fetch(URL)
59 if (res.ok) {
60 up = true
61 break
62 }
63 } catch {
64 /* not listening yet */
65 }
66 await new Promise((r) => setTimeout(r, 250))
67 }
68 if (!up) {
69 stop()
70 throw new Error(`vite preview never answered on ${URL}`)
71 }
74mkdirSync(join(root, 'tmp'), { recursive: true })
75const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
76const page = await browser.newPage()
77await page.setViewport({ width: 1400, height: 1100 })
79const problems = []
80// numbl announces which LAPACK bridge it picked on console.error; that is
81// information, not a fault
82const BENIGN = /using bridge:/
83page.on('console', (m) => {
84 if (m.type() === 'error' && !BENIGN.test(m.text())) problems.push(`console.error: ${m.text()}`)
85})
86page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`))
87page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()}`))
89const settle = (timeout = 420000) =>
90 page.waitForFunction(
91 () => {
92 const labs = [...document.querySelectorAll('.lab')]
93 if (labs.length === 0) return document.querySelector('.chapter') !== null
94 return labs.every((l) => {
95 const busy = [...l.querySelectorAll('button')].some((b) => /running/.test(b.textContent))
96 return !busy && !l.querySelector('.lab-placeholder')
97 })
98 },
99 { timeout, polling: 500 },
100 )
102try {
103 await page.goto(URL, { waitUntil: 'networkidle2' })
104 const chapters = await page.evaluate(() =>
105 [...document.querySelectorAll('.nav-item')].map((b) => b.textContent.replace(/^\d+/, '').trim()),
106 )
107 check('the sidebar lists chapters', chapters.length > 1, `${chapters.length} entries`)
109 for (let i = 0; i < chapters.length; i++) {
110 const name = chapters[i]
111 if (ONLY && !name.toLowerCase().includes(ONLY.toLowerCase())) continue
112 console.log(`\n${name}`)
113 await page.evaluate((k) => {
114 document.querySelectorAll('.nav-item')[k].click()
115 }, i)
116 await new Promise((r) => setTimeout(r, 300))
117 // labs that wait to be asked: ask them
118 await page.evaluate(() => {
119 for (const lab of document.querySelectorAll('.lab')) {
120 if (!lab.querySelector('.lab-placeholder')) continue
121 const b = [...lab.querySelectorAll('button')].find((x) => /^run$/.test(x.textContent.trim()))
122 b?.click()
123 }
124 })
125 await new Promise((r) => setTimeout(r, 300))
126 try {
127 await settle()
128 } catch {
129 check(`${name}: labs finish`, false, 'timed out')
130 continue
131 }
132 const info = await page.evaluate(() => {
133 const labs = [...document.querySelectorAll('.lab')]
134 return {
135 labs: labs.length,
136 errors: [...document.querySelectorAll('.lab-error')].map((e) => e.textContent.slice(0, 300)),
137 drawn: [...document.querySelectorAll('.lab-figures svg path')].filter(
138 (p) => (p.getAttribute('d') ?? '').length > 12,
139 ).length,
140 canvases: document.querySelectorAll('.lab-figures canvas').length,
141 stats: [...document.querySelectorAll('.stat-value')].map((s) => s.textContent),
142 }
143 })
144 check(`${name}: no MATLAB error`, info.errors.length === 0, info.errors.join(' | '))
145 if (info.labs > 0) {
146 check(
147 `${name}: figures carry data`,
148 info.drawn + info.canvases > 0,
149 `${info.drawn} paths, ${info.canvases} canvases`,
150 )
151 }
152 if (info.labs > 0) console.log(` ${info.stats.join(' ')}`)
153 if (SHOTS) {
154 const file = join(root, 'tmp', `ch-${String(i + 1).padStart(2, '0')}.png`)
155 await page.screenshot({ path: file, fullPage: true })
156 console.log(` shot: ${file}`)
157 }
158 }
160 check('no console errors', problems.length === 0, problems.slice(0, 4).join(' | '))
161} finally {
162 await browser.close()
163 stop()
166console.log(failures === 0 ? '\nall checks passed' : `\n${failures} check(s) failed`)
167process.exit(failures === 0 ? 0 : 1)
↑↓ move↵openescclose