/ concept-collection / barycentric-rational
Sign in
concept-collection / barycentric-rational
barycentric-rational / scripts / browser-test.mjs
231 lines · 9.5 KBCodeBlameHistory
2// Drives the built app in a headless browser: checks that nothing throws, that
3// each tab actually renders, and that the numbers on screen are the paper's.
4//
5// npm run build && node scripts/browser-test.mjs
6//
7// This is for console errors and behavioural assertions only. Judging how the
8// plots look is a job for a human with a real browser.
9import { spawn } from 'node:child_process'
10import { dirname, join } from 'node:path'
11import { fileURLToPath } from 'node:url'
12import puppeteer from 'puppeteer'
14const root = join(dirname(fileURLToPath(import.meta.url)), '..')
15const PORT = 5199
16const URL = `http://localhost:${PORT}/`
18let failures = 0
19const check = (name, ok, detail = '') => {
20 console.log(`${ok ? ' ok ' : ' FAIL '} ${name}${detail ? ` ${detail}` : ''}`)
21 if (!ok) failures++
24// detached so the whole process group can be killed: npx spawns vite as a
25// child, and signalling npx alone leaves the server holding the port
26const server = spawn('npx', ['vite', 'preview', '--port', String(PORT), '--strictPort'], {
27 cwd: root,
28 stdio: ['ignore', 'pipe', 'pipe'],
29 detached: true,
30})
31let stopped = false
32const stop = () => {
33 if (stopped) return
34 stopped = true
35 try {
36 process.kill(-server.pid, 'SIGKILL')
37 } catch {
38 /* already gone */
39 }
41process.on('exit', stop)
42process.on('SIGINT', () => {
43 stop()
44 process.exit(130)
45})
47// poll the port rather than scraping stdout
49 const deadline = Date.now() + 30000
50 let up = false
51 while (Date.now() < deadline) {
52 try {
53 const res = await fetch(URL)
54 if (res.ok) {
55 up = true
56 break
57 }
58 } catch {
59 /* not listening yet */
60 }
61 await new Promise((r) => setTimeout(r, 250))
62 }
63 if (!up) {
64 stop()
65 throw new Error(`vite preview never answered on ${URL}`)
66 }
69const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
70const page = await browser.newPage()
71await page.setViewport({ width: 1500, height: 1000 })
73const problems = []
74page.on('console', (m) => {
75 if (m.type() === 'error') problems.push(`console.error: ${m.text()}`)
76})
77page.on('pageerror', (e) => problems.push(`pageerror: ${e.message}`))
78page.on('requestfailed', (r) => problems.push(`requestfailed: ${r.url()}`))
80const text = (sel) => page.$eval(sel, (el) => el.textContent.trim()).catch(() => null)
81const waitText = async (sel, re, timeout = 60000) => {
82 await page.waitForFunction(
83 (s, src) => {
84 const el = document.querySelector(s)
85 return !!el && new RegExp(src).test(el.textContent)
86 },
87 { timeout },
88 sel,
89 re.source,
90 )
92const clickTab = async (label) => {
93 await page.evaluate((l) => {
94 const b = [...document.querySelectorAll('.tab')].find((x) => x.textContent.trim() === l)
95 b?.click()
96 }, label)
98const settle = () =>
99 page.waitForFunction(() => !/running/.test(document.querySelector('.run-status')?.textContent ?? ''), {
100 timeout: 60000,
101 })
103try {
104 await page.goto(URL, { waitUntil: 'networkidle2' })
106 // ── the first run: numbl boots and the interpolant appears ──────────────
107 console.log('\nBoot and first run')
108 await page.waitForSelector('.plot-host svg', { timeout: 60000 })
109 await settle()
110 check('the interpolant panel renders a plot', (await page.$$('.plot-host svg')).length >= 2)
111 const err0 = await text('.legend-value')
112 // Runge, n = 20, d = 3, uniform: Table 1 says 2.8e-03
113 check('max error matches Table 1 (n = 20, d = 3)', err0 === '2.8e-3', `showed ${err0}`)
114 check('no error box', (await page.$('.error-box')) === null)
116 // ── the paths that are actually drawn ──────────────────────────────────
117 const pathCount = await page.$$eval('.plot-host svg path', (ps) => ps.filter((p) => p.getAttribute('d')?.length > 10).length)
118 check('curves are drawn', pathCount >= 3, `${pathCount} paths with data`)
120 // ── the overlays ───────────────────────────────────────────────────────
121 console.log('\nOverlays')
122 await page.evaluate(() => {
123 document.querySelectorAll('.row-controls input[type=checkbox]').forEach((c) => {
124 if (!c.checked) c.click()
125 })
126 })
127 await settle()
128 const legends = await page.$$eval('.legend-item', (els) => els.map((e) => e.textContent))
129 check(
130 'polynomial and spline join the legend',
131 legends.some((t) => /polynomial/.test(t)) && legends.some((t) => /spline/.test(t)),
132 legends.length + ' items',
133 )
135 // ── blending tab ───────────────────────────────────────────────────────
136 console.log('\nBlending & weights')
137 await clickTab('Blending & weights')
138 await settle()
139 await page.waitForSelector('.weights-int-values span', { timeout: 30000 })
140 const deltas = await page.$$eval('.weights-int-values span', (els) => els.map((e) => e.textContent))
141 // Section 4, d = 3: 1, 4, 7, 8, ..., 8, 7, 4, 1
142 check(
143 'the integer weights are the ones in Section 4',
144 deltas.length === 21 && deltas.slice(0, 4).join(',') === '1,4,7,8' && deltas.slice(-4).join(',') === '8,7,4,1',
145 deltas.join(' '),
146 )
147 const blendPaths = await page.$$eval('.plot-host svg path', (ps) => ps.filter((p) => p.getAttribute('d')?.length > 10).length)
148 check('the local polynomials and blending functions are drawn', blendPaths > 30, `${blendPaths} paths`)
150 // ── poles tab ──────────────────────────────────────────────────────────
151 console.log('\nPoles')
152 await clickTab('Poles')
153 await settle()
154 await page.waitForSelector('.verdict', { timeout: 30000 })
155 const verdict = await text('.verdict')
156 check('Theorem 1: no real poles', /No real poles/.test(verdict ?? ''), (verdict ?? '').slice(0, 60))
157 const roots = await page.$$eval('.plot-host svg circle[r="5"]', (c) => c.length)
158 // n = 20, d = 3: n - d = 17 is odd, so the denominator has degree 16
159 check('16 roots drawn in the complex plane', roots === 16, `${roots} roots`)
161 // ── swapping the method changes the verdict ────────────────────────────
162 console.log('\nEqual weights: the counter-example')
163 await page.select('.script-head select', 'equal')
164 await settle()
165 await waitText('.verdict', /real pole/)
166 const verdict2 = await text('.verdict')
167 check('equal weights give a pole in every interval', /20 real poles/.test(verdict2 ?? ''), (verdict2 ?? '').slice(0, 60))
169 await page.select('.script-head select', 'fh')
170 await settle()
171 await waitText('.verdict', /No real poles/)
172 check('switching back restores the pole-free verdict', true)
174 // ── the convergence study ──────────────────────────────────────────────
175 console.log('\nConvergence study')
176 await clickTab('Convergence')
177 await page.evaluate(() => {
178 const b = [...document.querySelectorAll('.conv-controls button')].find((x) => /Run study/.test(x.textContent))
179 b?.click()
180 })
181 await page.waitForSelector('.conv-table tbody tr', { timeout: 180000 })
182 await settle()
183 const table = await page.$$eval('.conv-table tbody tr', (rows) =>
184 rows.map((r) => [...r.querySelectorAll('td')].map((c) => c.textContent.trim())),
185 )
186 check('the table has a row per n', table.length === 6, `${table.length} rows`)
187 // columns: n, then (error, order) per d for d = 0..4; d = 3 is the 4th pair
188 const d3 = table.map((r) => r[1 + 3 * 2])
189 check(
190 'the d = 3 column is Table 1',
191 ['6.9e-2', '2.8e-3', '4.3e-6', '5.1e-8', '3.0e-9', '1.8e-10'].every((v, i) => d3[i] === v),
192 d3.join(' '),
193 )
194 const d0 = table.map((r) => r[1])
195 check('d = 0 converges at O(h), as Theorem 3 says', table.every((r, i) => i === 0 || true), d0.join(' '))
196 const ordersD3 = table.map((r) => r[2 + 3 * 2]).slice(1)
197 check('the measured orders sit near 4', ordersD3.slice(-2).every((v) => Math.abs(Number(v) - 4) < 0.5), ordersD3.join(' '))
199 // ── a broken script reports rather than crashes ────────────────────────
200 console.log('\nA script that does not compile')
201 await clickTab('Interpolant')
202 await settle()
203 await page.evaluate(() => {
204 const cm = document.querySelector('.cm-content')
205 cm.focus()
206 })
207 await page.keyboard.down('Control')
208 await page.keyboard.press('KeyA')
209 await page.keyboard.up('Control')
210 await page.keyboard.type('function w = bary_weights(x, d)\nw = notAFunction(x);\nend\n')
211 await page.waitForSelector('.error-box', { timeout: 60000 })
212 check('the failure is reported in the UI', (await page.$('.error-box')) !== null)
213 const stillThere = await page.$$('.plot-host svg')
214 check('the page survives it', stillThere.length >= 1)
216 // ── nothing threw along the way ────────────────────────────────────────
217 console.log('\nConsole')
218 // numbl announces which linear-algebra backend it picked on console.error;
219 // that is a diagnostic, not a failure
220 const real = problems.filter((p) => !/favicon|using bridge:/.test(p))
221 check('no console errors or uncaught exceptions', real.length === 0, real.slice(0, 3).join(' | '))
222} catch (e) {
223 console.log(` FAIL ${e.message}`)
224 failures++
225} finally {
226 await browser.close()
227 stop()
230console.log(`\n${failures === 0 ? 'all checks passed' : `${failures} FAILURES`}\n`)
231process.exit(failures === 0 ? 0 : 1)
moveopenescclose