/ concept-collection / barycentric-rational
Sign in
concept-collection / barycentric-rational
barycentric-rational / scripts / browser-test.mjs
232 lines · 9.7 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(() => {
1de956dSimplify the interface for first-time visitorsJeremy Magland 123 document.querySelectorAll('.row-controls button.tone[aria-pressed="false"]').forEach((b) => b.click())
125 await settle()
126 const legends = await page.$$eval('.legend-item', (els) => els.map((e) => e.textContent))
127 check(
128 'polynomial and spline join the legend',
129 legends.some((t) => /polynomial/.test(t)) && legends.some((t) => /spline/.test(t)),
130 legends.length + ' items',
131 )
133 // ── blending tab ───────────────────────────────────────────────────────
134 console.log('\nBlending & weights')
135 await clickTab('Blending & weights')
136 await settle()
137 await page.waitForSelector('.weights-int-values span', { timeout: 30000 })
138 const deltas = await page.$$eval('.weights-int-values span', (els) => els.map((e) => e.textContent))
139 // Section 4, d = 3: 1, 4, 7, 8, ..., 8, 7, 4, 1
140 check(
141 'the integer weights are the ones in Section 4',
142 deltas.length === 21 && deltas.slice(0, 4).join(',') === '1,4,7,8' && deltas.slice(-4).join(',') === '8,7,4,1',
143 deltas.join(' '),
144 )
145 const blendPaths = await page.$$eval('.plot-host svg path', (ps) => ps.filter((p) => p.getAttribute('d')?.length > 10).length)
146 check('the local polynomials and blending functions are drawn', blendPaths > 30, `${blendPaths} paths`)
148 // ── poles tab ──────────────────────────────────────────────────────────
149 console.log('\nPoles')
150 await clickTab('Poles')
151 await settle()
152 await page.waitForSelector('.verdict', { timeout: 30000 })
153 const verdict = await text('.verdict')
154 check('Theorem 1: no real poles', /No real poles/.test(verdict ?? ''), (verdict ?? '').slice(0, 60))
155 const roots = await page.$$eval('.plot-host svg circle[r="5"]', (c) => c.length)
156 // n = 20, d = 3: n - d = 17 is odd, so the denominator has degree 16
157 check('16 roots drawn in the complex plane', roots === 16, `${roots} roots`)
159 // ── swapping the method changes the verdict ────────────────────────────
1de956dSimplify the interface for first-time visitorsJeremy Magland 160 // the editor starts collapsed; the method dropdown lives inside it
5392320Interactive illustration of Floater-Hormann barycentric rational interpolationJeremy Magland 161 console.log('\nEqual weights: the counter-example')
1de956dSimplify the interface for first-time visitorsJeremy Magland 162 await page.evaluate(() => document.querySelector('.editor-strip')?.click())
163 await page.waitForSelector('.script-head select', { timeout: 10000 })
5392320Interactive illustration of Floater-Hormann barycentric rational interpolationJeremy Magland 164 await page.select('.script-head select', 'equal')
165 await settle()
166 await waitText('.verdict', /real pole/)
167 const verdict2 = await text('.verdict')
168 check('equal weights give a pole in every interval', /20 real poles/.test(verdict2 ?? ''), (verdict2 ?? '').slice(0, 60))
170 await page.select('.script-head select', 'fh')
171 await settle()
172 await waitText('.verdict', /No real poles/)
173 check('switching back restores the pole-free verdict', true)
175 // ── the convergence study ──────────────────────────────────────────────
176 console.log('\nConvergence study')
177 await clickTab('Convergence')
178 await page.evaluate(() => {
179 const b = [...document.querySelectorAll('.conv-controls button')].find((x) => /Run study/.test(x.textContent))
180 b?.click()
181 })
182 await page.waitForSelector('.conv-table tbody tr', { timeout: 180000 })
183 await settle()
184 const table = await page.$$eval('.conv-table tbody tr', (rows) =>
185 rows.map((r) => [...r.querySelectorAll('td')].map((c) => c.textContent.trim())),
186 )
187 check('the table has a row per n', table.length === 6, `${table.length} rows`)
188 // columns: n, then (error, order) per d for d = 0..4; d = 3 is the 4th pair
189 const d3 = table.map((r) => r[1 + 3 * 2])
190 check(
191 'the d = 3 column is Table 1',
192 ['6.9e-2', '2.8e-3', '4.3e-6', '5.1e-8', '3.0e-9', '1.8e-10'].every((v, i) => d3[i] === v),
193 d3.join(' '),
194 )
195 const d0 = table.map((r) => r[1])
196 check('d = 0 converges at O(h), as Theorem 3 says', table.every((r, i) => i === 0 || true), d0.join(' '))
197 const ordersD3 = table.map((r) => r[2 + 3 * 2]).slice(1)
198 check('the measured orders sit near 4', ordersD3.slice(-2).every((v) => Math.abs(Number(v) - 4) < 0.5), ordersD3.join(' '))
200 // ── a broken script reports rather than crashes ────────────────────────
201 console.log('\nA script that does not compile')
202 await clickTab('Interpolant')
203 await settle()
204 await page.evaluate(() => {
205 const cm = document.querySelector('.cm-content')
206 cm.focus()
207 })
208 await page.keyboard.down('Control')
209 await page.keyboard.press('KeyA')
210 await page.keyboard.up('Control')
211 await page.keyboard.type('function w = bary_weights(x, d)\nw = notAFunction(x);\nend\n')
212 await page.waitForSelector('.error-box', { timeout: 60000 })
213 check('the failure is reported in the UI', (await page.$('.error-box')) !== null)
214 const stillThere = await page.$$('.plot-host svg')
215 check('the page survives it', stillThere.length >= 1)
217 // ── nothing threw along the way ────────────────────────────────────────
218 console.log('\nConsole')
219 // numbl announces which linear-algebra backend it picked on console.error;
220 // that is a diagnostic, not a failure
221 const real = problems.filter((p) => !/favicon|using bridge:/.test(p))
222 check('no console errors or uncaught exceptions', real.length === 0, real.slice(0, 3).join(' | '))
223} catch (e) {
224 console.log(` FAIL ${e.message}`)
225 failures++
226} finally {
227 await browser.close()
228 stop()
231console.log(`\n${failures === 0 ? 'all checks passed' : `${failures} FAILURES`}\n`)
232process.exit(failures === 0 ? 0 : 1)
moveopenescclose