1/**
2 * End-to-end check of the cache flow in headless Chrome (SwiftShader WebGPU),
3 * without touching the real cloud cache:
4 *
5 * 1. cache miss — the page is loaded with the real tempory.net requests
6 * intercepted to 404, the end time is set to 5, and the run computes
7 * locally; the produced .h5 is pulled out of the download link.
8 * 2. the .h5 is checked with Python h5py (layout, shapes, spec_json).
9 * 3. cache hit — a fresh page, same selection, with the interception now
10 * answering that .h5; the page must show "from the cloud cache".
11 * 4. warm start — a fresh page asking for a longer end time, with only the
12 * shorter run's .h5 in the "cache"; the page must resume from it and
13 * compute only the remainder.
14 *
15 * The pages are opened with the ?tend= test hook so the computed runs stay
16 * short (5 and 10 time units instead of the UI's 100+).
17 *
18 * Usage: node scripts/check-app.mjs
19 */
20import { createServer } from 'node:http';
21import { readFile, writeFile } from 'node:fs/promises';
22import { execFileSync } from 'node:child_process';
23import { extname, join } from 'node:path';
24import puppeteer from 'puppeteer-core';
26const DIST = new URL('../dist/', import.meta.url).pathname;
27const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' };
28const T_END = '5';
29const T_END_LONG = '10';
31const server = createServer(async (req, res) => {
32 try {
33 const path = req.url === '/' ? '/index.html' : req.url.split('?')[0];
34 const data = await readFile(join(DIST, path));
35 res.writeHead(200, { 'content-type': MIME[extname(path)] ?? 'application/octet-stream' });
36 res.end(data);
37 } catch {
38 res.writeHead(404);
39 res.end();
40 }
41});
42await new Promise((r) => server.listen(0, '127.0.0.1', r));
43const port = server.address().port;
45const browser = await puppeteer.launch({
46 executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome',
47 args: ['--headless=new', '--no-sandbox', '--enable-unsafe-webgpu',
48 '--use-webgpu-adapter=swiftshader', '--enable-unsafe-swiftshader'],
49});
51const problems = [];
52function watch(page, tag) {
53 page.on('pageerror', (e) => problems.push(`${tag} pageerror: ${e.message}`));
54 page.on('console', (m) => {
55 // Cache-lookup 404s log as resource errors by design; ignore them.
56 if (
57 m.type() === 'error' &&
58 !/GL Driver|favicon|tempory\.net|Failed to load resource/.test(m.text())
59 ) {
60 problems.push(`${tag} console error: ${m.text()}`);
61 }
62 });
63}
65/** Intercept tempory.net cache reads; `bytes` null => everything 404s. The
66 * mocked responses need the CORS header the real bucket sends, or the page's
67 * cross-origin fetch is blocked before it sees the status. */
68const CORS = { 'access-control-allow-origin': '*' };
69async function interceptCache(page, bytes, name) {
70 await page.setRequestInterception(true);
71 page.on('request', (req) => {
72 const url = req.url();
73 if (!url.startsWith('https://tempory.net/')) return void req.continue();
74 if (bytes && name && url.endsWith(`/${name}`)) {
75 if (req.method() === 'HEAD') return void req.respond({ status: 200, headers: CORS });
76 return void req.respond({
77 status: 200,
78 headers: CORS,
79 contentType: 'application/x-hdf5',
80 body: Buffer.from(bytes),
81 });
82 }
83 req.respond({ status: 404, headers: CORS, body: 'not found' });
84 });
85}
87/** Open the app. `tend` selects an end time through the dropdown; a `hash`
88 * instead carries the selection in the URL fragment, exercising the
89 * reload/share restore path. `hookOrder` sets the ?tend test list — its
90 * first entry is the default selection, so a restore test must pass an
91 * order whose default differs from the hash value, or a restore that
92 * silently does nothing would still land on the right selection. */
93async function openAndSelect(page, tend, hash = '', hookOrder = `${T_END},${T_END_LONG}`) {
94 await page.setViewport({ width: 1100, height: 900 });
95 // The ?tend hook replaces the end-time list with short test values.
96 await page.goto(
97 `http://127.0.0.1:${port}/index.html?tend=${hookOrder}${hash}`,
98 { waitUntil: 'load' },
99 );
100 // Selection changes land long before the WebGPU compile finishes, so the
101 // boot-time auto-refresh picks them up.
102 await page.waitForSelector('#tend');
103 if (tend !== null) await page.select('#tend', tend);
104}
106const statusOf = (page) => page.$eval('#status', (el) => el.textContent);
107const errOf = (page) => page.$eval('#err', (el) => el.textContent);
109try {
110 // ---- pass 1: miss, compute locally --------------------------------------
111 const page1 = await browser.newPage();
112 watch(page1, 'miss:');
113 await interceptCache(page1, null, '');
114 await openAndSelect(page1, T_END);
115 // A miss never computes on its own: the page must settle on empty windows
116 // asking for the button.
117 await page1.waitForFunction(
118 () => /press Compute solution|failed/.test(
119 document.getElementById('status')?.textContent ?? '') ||
120 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
121 { timeout: 600_000 },
122 );
123 console.log('pass 1 idle status:', await statusOf(page1));
124 const solveDisabled = await page1.$eval('#solve', (b) => b.disabled);
125 if (solveDisabled) problems.push('miss: Compute solution button disabled while idle');
126 await page1.click('#solve');
127 // "Not uploaded" is the terminal state of a keyless local run — it appears
128 // only after the .h5 has been encoded and the download link filled in.
129 await page1.waitForFunction(
130 () => /Not uploaded|failed/.test(document.getElementById('status')?.textContent ?? '') ||
131 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
132 { timeout: 600_000 },
133 );
134 const s1 = await statusOf(page1);
135 console.log('pass 1 status:', s1);
136 const e1 = await errOf(page1);
137 if (e1) problems.push(`miss: err: ${e1}`);
138 if (!/computed locally/.test(s1)) problems.push(`miss: unexpected status: ${s1}`);
139 if (!/t = 5\b/.test(s1)) problems.push(`miss: did not stop at t = 5: ${s1}`);
141 const fileName = await page1.$eval('#download', (a) => a.download);
142 const b64 = await page1.$eval('#download', async (a) => {
143 const buf = await (await fetch(a.href)).arrayBuffer();
144 let out = '';
145 const v = new Uint8Array(buf);
146 for (let i = 0; i < v.length; i += 0x8000) {
147 out += String.fromCharCode(...v.subarray(i, i + 0x8000));
148 }
149 return btoa(out);
150 });
151 const bytes = Buffer.from(b64, 'base64');
152 console.log(`downloaded ${fileName}: ${bytes.length} bytes`);
153 if (!/^[0-9a-f]{64}\.h5$/.test(fileName)) problems.push(`odd file name: ${fileName}`);
154 // The selection is mirrored into the URL fragment on every change.
155 if (!new URL(page1.url()).hash.includes(`tend=${T_END}`)) {
156 problems.push(`miss: selection not in URL: ${page1.url()}`);
157 }
158 const h5Path = `/tmp/turing-surface-cache-check.h5`;
159 await writeFile(h5Path, bytes);
160 await page1.close();
162 // ---- pass 2: the .h5 itself, via h5py -----------------------------------
163 try {
164 const out = execFileSync('python3', ['-c', `
165import h5py, json, sys
166f = h5py.File('${h5Path}', 'r')
167spec = json.loads(f.attrs['spec_json'])
168assert f.attrs['app'] == 'turing-surface-cache', f.attrs['app']
169assert int(f.attrs['format_version']) == 1
170assert spec['tEnd'] == 5 and spec['model'] == 'schnakenberg', spec
171assert int(f['spec'].attrs['steps']) == round(5 / spec['params']['dt'])
172nlm = (spec['lmax'] + 1) * (spec['lmax'] + 2) // 2
173for g in ('geometry/Gx', 'geometry/Gy', 'geometry/Gz', 'initial/U', 'initial/V', 'final/U', 'final/V'):
174 d = f[g]
175 assert d.shape == (2 * nlm,) and d.dtype.kind == 'f', (g, d.shape, d.dtype)
176import numpy as np
177assert np.isfinite(f['final/U'][:]).all() and np.abs(f['final/U'][:]).max() > 0
178print('h5py check ok; species', list(f.attrs['species']), '; adapter:', f.attrs.get('adapter', '?'))
179`], { encoding: 'utf8' });
180 console.log('pass 2:', out.trim());
181 } catch (e) {
182 problems.push(`h5py check failed: ${e.stdout ?? ''}${e.stderr ?? e.message}`);
183 }
185 // ---- pass 3: hit, load from "cache", selection restored from the URL ----
186 const page2 = await browser.newPage();
187 watch(page2, 'hit:');
188 await interceptCache(page2, bytes, fileName);
189 // No dropdown interaction: the end time arrives in the fragment, as it
190 // would from a shared or reloaded link. The hook default is deliberately
191 // the OTHER value, so only a working restore reaches the cached spec.
192 await openAndSelect(page2, null, `#tend=${T_END}`, `${T_END_LONG},${T_END}`);
193 const restored = await page2.$eval('#tend', (el) => el.value);
194 if (restored !== T_END) problems.push(`hit: URL restore failed, tend = ${restored}`);
195 // "from the cloud cache" is the hit's terminal status; "checking the cloud
196 // cache…" is transient and must not satisfy the wait.
197 await page2.waitForFunction(
198 () => /from the cloud cache|Not uploaded|failed/.test(
199 document.getElementById('status')?.textContent ?? '') ||
200 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
201 { timeout: 600_000 },
202 );
203 const s2 = await statusOf(page2);
204 console.log('pass 3 status:', s2);
205 const e2 = await errOf(page2);
206 if (e2) problems.push(`hit: err: ${e2}`);
207 if (!/from the.*cloud cache/.test(s2)) problems.push(`hit: expected a cache hit: ${s2}`);
208 const note = await page2.$eval('#cachenote', (el) => el.textContent);
209 if (!/in the cloud cache/.test(note)) problems.push(`hit: cache note wrong: '${note}'`);
210 const panels = await page2.$$eval('.sphere-box canvas', (els) => els.length);
211 if (panels !== 2) problems.push(`hit: expected 2 sphere canvases, got ${panels}`);
212 await page2.close();
214 // ---- pass 4: warm start from the shorter cached run ----------------------
215 // Only the t = 5 file is in the "cache"; asking for t = 10 must resume from
216 // it and compute just the remainder.
217 const page3 = await browser.newPage();
218 watch(page3, 'warm:');
219 await interceptCache(page3, bytes, fileName);
220 await openAndSelect(page3, T_END_LONG);
221 await page3.waitForFunction(
222 () => /press Compute solution|failed/.test(
223 document.getElementById('status')?.textContent ?? '') ||
224 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
225 { timeout: 600_000 },
226 );
227 await page3.click('#solve');
228 await page3.waitForFunction(
229 () => /Not uploaded|failed/.test(document.getElementById('status')?.textContent ?? '') ||
230 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
231 { timeout: 600_000 },
232 );
233 const s3 = await statusOf(page3);
234 console.log('pass 4 status:', s3);
235 const e3 = await errOf(page3);
236 if (e3) problems.push(`warm: err: ${e3}`);
237 if (!/t = 10\b/.test(s3)) problems.push(`warm: did not stop at t = 10: ${s3}`);
238 if (!/resumed from cached t = 5\b/.test(s3)) {
239 problems.push(`warm: expected a resume from t = 5: ${s3}`);
240 }
241 const warmFile = await page3.$eval('#download', (a) => a.download);
242 if (warmFile === fileName || !/^[0-9a-f]{64}\.h5$/.test(warmFile)) {
243 problems.push(`warm: odd file name: ${warmFile}`);
244 }
245 await page3.close();
247 // ---- pass 5: model in the URL, and a live model switch -------------------
248 // Allen–Cahn arrives via the fragment (one species -> one panel); switching
249 // to Brusselator recompiles the session and rebuilds the panels (two).
250 // Nothing computes: both settle on the idle miss status.
251 const page4 = await browser.newPage();
252 watch(page4, 'model:');
253 await interceptCache(page4, null, '');
254 await openAndSelect(page4, null, '#model=allencahn');
255 await page4.waitForFunction(
256 () => /press Compute solution|failed/.test(
257 document.getElementById('status')?.textContent ?? '') ||
258 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
259 { timeout: 600_000 },
260 );
261 const modelRestored = await page4.$eval('#model', (el) => el.value);
262 if (modelRestored !== 'allencahn') {
263 problems.push(`model: URL restore failed, model = ${modelRestored}`);
264 }
265 const acPanels = await page4.$$eval('.sphere-box canvas', (els) => els.length);
266 if (acPanels !== 1) problems.push(`model: allencahn should have 1 panel, got ${acPanels}`);
267 await page4.select('#model', 'brusselator');
268 // The old idle status is still on screen while the recompile runs, so the
269 // wait must demand the new panel count as well.
270 await page4.waitForFunction(
271 () => (document.querySelectorAll('.sphere-box canvas').length === 2 &&
272 /press Compute solution/.test(document.getElementById('status')?.textContent ?? '')) ||
273 /failed/.test(document.getElementById('status')?.textContent ?? '') ||
274 (document.getElementById('err')?.textContent?.length ?? 0) > 4,
275 { timeout: 600_000 },
276 );
277 const brPanels = await page4.$$eval('.sphere-box canvas', (els) => els.length);
278 if (brPanels !== 2) problems.push(`model: brusselator should have 2 panels, got ${brPanels}`);
279 const e4 = await errOf(page4);
280 if (e4) problems.push(`model: err: ${e4}`);
281 console.log(`pass 5: allencahn ${acPanels} panel, brusselator ${brPanels} panels`);
282 await page4.close();
283} catch (e) {
284 problems.push(`fatal: ${e.message}`);
285} finally {
286 await browser.close();
287 server.close();
288}
290if (problems.length) {
291 console.log('PROBLEMS:');
292 for (const p of new Set(problems)) console.log(' ' + p);
293 process.exitCode = 1;
294} else {
295 console.log('CHECK-APP: PASS');
296}