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