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