oxedyne/daimond/dev/verify_interfacediagram.mjs
17.2 KiB, 1 run
created by r2519314175:489, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // verify_interfacediagram.mjs — the interface page's region map says what the |
| 2 | // app does, and is still made of words on a phone. |
| 3 | // |
| 4 | // Two properties, and one of them was a live defect. |
| 5 | // |
| 6 | // 1. THE TOP BAR HAS NO SPEND METER. `#top-meter` has since gone from |
| 7 | // www/index.html altogether -- markup, binding and the one line that |
| 8 | // emptied it -- so nothing in the tree writes it and nothing could. The |
| 9 | // check is unchanged and now rests on a stronger fact; what had to move is |
| 10 | // the `liveMeter` break, which staged the contrary case off that deleted |
| 11 | // line. See the note on it below. |
| 12 | // The readout the guide was describing is the rail's `#spend-row`, |
| 13 | // which `updateSpend()` fills with three cells and which `spend.js` wires |
| 14 | // as the door to the Spending panel. So the guide must not put a meter in |
| 15 | // the top bar, on `interface.html` or on `spending.html`, and the region |
| 16 | // map must not draw one. Checked against the CODE, not against a list of |
| 17 | // words: the assertion is "nothing writes #top-meter", so the day someone |
| 18 | // revives the meter this check turns round and demands the guide say so. |
| 19 | // |
| 20 | // 2. THE DIAGRAM'S WORDS ARE STILL WORDS AT 360px. Scaled to a phone column |
| 21 | // the whole schematic put its smallest labels near five pixels. Measured |
| 22 | // as RENDERED INK -- the height of a label's own box on the page as the |
| 23 | // reader has it -- and not as a font-size in the markup, which says |
| 24 | // nothing once an SVG has been scaled to fit. The page must also not |
| 25 | // scroll sideways: the figure scrolls inside its own box instead. |
| 26 | // |
| 27 | // EACH CHECK IS PROVED AGAINST A BROKEN PAGE FIRST. `--break <name>` damages a |
| 28 | // copy of a file and serves it to the real browser through `page.route`, or |
| 29 | // damages the input a static check reads, and the run is expected to FAIL. |
| 30 | // |
| 31 | // node dev/verify_interfacediagram.mjs --break meterprose # 1 fails |
| 32 | // node dev/verify_interfacediagram.mjs --break meterdoor # 1 fails |
| 33 | // node dev/verify_interfacediagram.mjs --break metersvg # 1 fails |
| 34 | // node dev/verify_interfacediagram.mjs --break liveMeter # 1 fails, the other way |
| 35 | // node dev/verify_interfacediagram.mjs --break tinylabels # 2 fails |
| 36 | // node dev/verify_interfacediagram.mjs --break nofloor # 2 fails |
| 37 | // node dev/verify_interfacediagram.mjs # and then, clean |
| 38 | // |
| 39 | // bash dev/world.sh 7 --up && eval "$(bash dev/world.sh 7 --env)" |
| 40 | // node dev/verify_interfacediagram.mjs |
| 41 | // |
| 42 | // Needs dev/serve.mjs only: the guide is flat files and loads none of the app. |
| 43 | // Writes its screenshots to dev/shots/ifdiag-*.png. |
| 44 | import fs from 'node:fs'; |
| 45 | import os from 'node:os'; |
| 46 | import path from 'node:path'; |
| 47 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 48 | |
| 49 | // Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever |
| 50 | // `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed |
| 51 | // run under `xvfb-run` still went to the compositor and opened a window on the owner's |
| 52 | // desktop. Importing this strips the two variables from `process.env`, which is all a |
| 53 | // launcher that spreads `process.env` needs. See dev/display.mjs. |
| 54 | import './display.mjs'; |
| 55 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 56 | const WWW = path.join(HERE, '..', 'www'); |
| 57 | const GUIDE = path.join(WWW, 'guide'); |
| 58 | const PAGE = path.join(GUIDE, 'interface.html'); |
| 59 | const SPEND = path.join(GUIDE, 'spending.html'); |
| 60 | const SHOTS = path.join(HERE, 'shots'); |
| 61 | /// A prove-run renders damaged pages, so its shots go to their own names. The |
| 62 | /// first draft wrote over the real ones, and the clean 360px shot on disk was |
| 63 | /// the one taken with the width floor deliberately removed. |
| 64 | const tag = (n) => `ifdiag-${n}${BREAK ? '-BREAK-' + BREAK : ''}.png`; |
| 65 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 66 | const PW = process.env.DAIMOND_PW |
| 67 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 68 | const CHROME = process.env.DAIMOND_CHROME |
| 69 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 70 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 71 | |
| 72 | const BREAK = (() => { |
| 73 | const i = process.argv.indexOf('--break'); |
| 74 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 75 | })(); |
| 76 | |
| 77 | const ok = [], bad = []; |
| 78 | const check = (name, pass, detail) => { |
| 79 | (pass ? ok : bad).push(name); |
| 80 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 81 | }; |
| 82 | const die = (why) => { console.error('ABORT: ' + why); process.exit(2); }; |
| 83 | |
| 84 | // ── The inputs, and the damage ─────────────────────────────────────── |
| 85 | let pageBytes = fs.readFileSync(PAGE, 'utf8'); |
| 86 | let spendBytes = fs.readFileSync(SPEND, 'utf8'); |
| 87 | let appJs = fs.readFileSync(path.join(WWW, 'js', 'daimond.js'), 'utf8'); |
| 88 | let spendJs = fs.readFileSync(path.join(WWW, 'js', 'spend.js'), 'utf8'); |
| 89 | |
| 90 | const applied = []; |
| 91 | switch (BREAK) { |
| 92 | case '': break; |
| 93 | case 'meterprose': { |
| 94 | // The sentence as it stood: a meter in the top bar, which is not there. |
| 95 | const before = pageBytes; |
| 96 | pageBytes = pageBytes.replace('Across the top sit the wordmark, a row of', |
| 97 | 'Across the top sit the wordmark, a spend meter, a row of'); |
| 98 | if (pageBytes === before) die('the meterprose break did not apply'); |
| 99 | applied.push('put the spend meter back in the top bar paragraph'); |
| 100 | break; |
| 101 | } |
| 102 | case 'meterdoor': { |
| 103 | // The other half of the same false claim, on the other page: the door to |
| 104 | // the Spending panel described as being in the top bar. |
| 105 | const before = spendBytes; |
| 106 | spendBytes = spendBytes.replace(/Open the Spending panel from the <strong>spend row<\/strong> at the foot of the rail/, |
| 107 | 'Open the Spending panel from the <strong>spend meter</strong> in the top bar'); |
| 108 | if (spendBytes === before) die('the meterdoor break did not apply'); |
| 109 | applied.push('sent the reader to a top-bar meter for the Spending panel'); |
| 110 | break; |
| 111 | } |
| 112 | case 'metersvg': { |
| 113 | // The pill-and-fill the region map drew for it, back in the top bar. |
| 114 | const before = pageBytes; |
| 115 | pageBytes = pageBytes.replace('<rect class="rm-cell" x="430" y="25" width="40" height="16" rx="8"/>', |
| 116 | '<rect class="rm-cell" x="322" y="24" width="86" height="18" rx="9"/>\n' |
| 117 | + '\t\t\t<rect class="rm-fill" x="326" y="28" width="34" height="10" rx="5" opacity=".55"/>\n' |
| 118 | + '\t\t\t<rect class="rm-cell" x="430" y="25" width="40" height="16" rx="8"/>'); |
| 119 | if (pageBytes === before) die('the metersvg break did not apply'); |
| 120 | applied.push('drew a meter in the region map again'); |
| 121 | break; |
| 122 | } |
| 123 | case 'liveMeter': { |
| 124 | // The opposite failure: the app grows a meter and the guide stays silent. |
| 125 | // The check must notice that too, or it is a check on one wording. |
| 126 | // |
| 127 | // REPOINTED. This used to hang off `topMeter.textContent = '';` -- the one |
| 128 | // line that emptied the element -- and both the element and that line have |
| 129 | // since been deleted from the app, markup and binding with them. The |
| 130 | // anchor was gone, so the only break that could turn this check round no |
| 131 | // longer applied, and a check whose contrary case cannot be staged is a |
| 132 | // check that cannot fail. It now hangs off `updateMeters()` itself, which |
| 133 | // is where a revived meter would be written, and reaches the element the |
| 134 | // way anything outside this module would have to. |
| 135 | const before = appJs; |
| 136 | appJs = appJs.replace('\t\taiMeter.textContent = \'\';\n\t}', |
| 137 | '\t\taiMeter.textContent = \'\';\n' |
| 138 | + '\t\tdocument.getElementById(\'top-meter\').textContent = fmtUsd(spentToday());\n\t}'); |
| 139 | if (appJs === before) die('the liveMeter break did not apply'); |
| 140 | applied.push('made updateMeters() write a top-bar meter'); |
| 141 | break; |
| 142 | } |
| 143 | case 'tinylabels': |
| 144 | case 'nofloor': { |
| 145 | // The width floor removed, so the schematic is scaled to the phone column |
| 146 | // and its labels go with it. This is what the page did before this file. |
| 147 | const before = pageBytes; |
| 148 | pageBytes = pageBytes.replace(/\.diagram\.scrolls svg \{ min-width: \d+px; \}/, ''); |
| 149 | if (pageBytes === before) die('the width-floor break did not apply'); |
| 150 | applied.push('let the region map scale down to the phone column'); |
| 151 | break; |
| 152 | } |
| 153 | default: die(`no break called "${BREAK}"`); |
| 154 | } |
| 155 | if (BREAK) console.log(`BREAK ${BREAK}: ${applied.join('; ')}\n`); |
| 156 | |
| 157 | // ── 1. The top bar has no spend meter ──────────────────────────────── |
| 158 | // |
| 159 | // The oracle is the code. `#top-meter` exists in the markup, so its presence |
| 160 | // proves nothing; what decides the question is whether anything ever puts |
| 161 | // characters in it. `updateMeters()` clears it and no other statement in the |
| 162 | // tree assigns to it, so the element is furniture with no content. A guide that |
| 163 | // names it is describing a control the reader cannot find. |
| 164 | { |
| 165 | // Every assignment to the element, however it is reached: the variable |
| 166 | // daimond.js binds it to, and a fresh lookup anywhere else. |
| 167 | const writes = []; |
| 168 | const files = []; |
| 169 | const walk = (dir) => { |
| 170 | for (const e of fs.readdirSync(dir, { withFileTypes: true })) { |
| 171 | const p = path.join(dir, e.name); |
| 172 | if (e.isDirectory()) walk(p); |
| 173 | else if (/\.(js|mjs)$/.test(e.name)) files.push(p); |
| 174 | } |
| 175 | }; |
| 176 | walk(path.join(WWW, 'js')); |
| 177 | // The named binding, from daimond.js's own `var topMeter = getElementById(...)`. |
| 178 | const RE = /\b(topMeter|document\.getElementById\(\s*'top-meter'\s*\))\s*\.\s*(textContent|innerHTML|innerText|append\w*|replaceChildren)\s*(=[^=]|\()/g; |
| 179 | for (const f of files) { |
| 180 | const src = f.endsWith('daimond.js') ? appJs : (f.endsWith('spend.js') ? spendJs : fs.readFileSync(f, 'utf8')); |
| 181 | let m; |
| 182 | while ((m = RE.exec(src))) { |
| 183 | // An assignment of the empty string is a clearing, not a readout. |
| 184 | const tail = src.slice(m.index, m.index + 200); |
| 185 | if (/=\s*''\s*;/.test(tail.slice(0, 40))) continue; |
| 186 | writes.push(`${path.basename(f)}: ${tail.split('\n')[0].trim().slice(0, 60)}`); |
| 187 | } |
| 188 | } |
| 189 | const live = writes.length > 0; |
| 190 | |
| 191 | // What the two pages say. The claim is "a spend readout in the top bar", in |
| 192 | // any wording: a named thing, in the named place, within one sentence. |
| 193 | const proseOf = (b) => (b.match(/<main[^>]*>([\s\S]*?)<\/main>/i) || [, ''])[1] |
| 194 | .replace(/<svg[\s\S]*?<\/svg>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' '); |
| 195 | const CLAIM = /\bspend(ing)?\s+(meter|readout|total|figure)\b[^.]{0,60}\btop bar\b|\btop bar\b[^.]{0,60}\bspend(ing)?\s+(meter|readout|total|figure)\b/i; |
| 196 | const said = []; |
| 197 | if (CLAIM.test(proseOf(pageBytes))) said.push('interface.html'); |
| 198 | if (CLAIM.test(proseOf(spendBytes))) said.push('spending.html'); |
| 199 | |
| 200 | // And the region map must not draw one. The meter was a filled pill in the |
| 201 | // top-bar strip: a `rm-fill` rectangle inside the bar's own band (y < 56), |
| 202 | // which is otherwise only the brand diamond, drawn rotated. |
| 203 | const svg = (pageBytes.match(/<svg class="regionmap"[\s\S]*?<\/svg>/) || [''])[0]; |
| 204 | const drawn = [...svg.matchAll(/<rect class="rm-fill"[^>]*y="(\d+(?:\.\d+)?)"[^>]*>/g)] |
| 205 | .filter((m) => Number(m[1]) < 56 && !/rotate/.test(m[0])); |
| 206 | |
| 207 | // The two halves have to agree with the code, in whichever direction it points. |
| 208 | const agrees = live ? (said.length === 2) : (said.length === 0 && drawn.length === 0); |
| 209 | check('the guide and the code agree about a spend meter in the top bar', agrees, |
| 210 | live ? `nothing writes #top-meter is FALSE (${writes[0]}), and the guide says it on: ${said.join(', ') || 'no page'}` |
| 211 | : `nothing writes #top-meter, but ${said.join(' and ') || 'the diagram'} still ${said.length ? 'says so' : 'draws one'}`); |
| 212 | |
| 213 | // And the reader is sent to the door that exists. `spend.js` puts the click |
| 214 | // handler on `#spend-row`, so that is where the guide has to point. |
| 215 | const door = /getElementById\('spend-row'\)[\s\S]{0,600}?addEventListener\('click'/.test(spendJs); |
| 216 | const points = /spend row[^.]{0,60}(rail|foot of the rail)|(rail|foot of the rail)[^.]{0,60}spend row/i |
| 217 | .test(proseOf(spendBytes)); |
| 218 | check('spending.html points at the door spend.js actually wires', !door || points, |
| 219 | door ? 'the handler is on #spend-row and the page does not say so' : 'no handler found'); |
| 220 | } |
| 221 | |
| 222 | // ── The browser ────────────────────────────────────────────────────── |
| 223 | const { chromium } = await import(pathToFileURL(PW).href); |
| 224 | const profile = path.join(SCRATCH, 'pw', 'ifdiag' + (BREAK ? '-' + BREAK : '')); |
| 225 | fs.rmSync(profile, { recursive: true, force: true }); |
| 226 | fs.mkdirSync(profile, { recursive: true }); |
| 227 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 228 | |
| 229 | // A forwarded DISPLAY means no compositor frames, so requestAnimationFrame never |
| 230 | // fires and every wait hangs. See dev/harness.mjs. |
| 231 | const env = { ...process.env }; |
| 232 | delete env.DISPLAY; |
| 233 | |
| 234 | const browser = await chromium.launchPersistentContext(profile, { |
| 235 | executablePath: CHROME, |
| 236 | headless: false, |
| 237 | args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'], |
| 238 | env, |
| 239 | viewport: { width: 1100, height: 900 }, |
| 240 | }); |
| 241 | const page = browser.pages()[0] || await browser.newPage(); |
| 242 | const errs = []; |
| 243 | page.on('pageerror', (e) => errs.push(String(e.message))); |
| 244 | page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); }); |
| 245 | |
| 246 | await page.route('**/guide/interface.html*', (route) => { |
| 247 | route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: pageBytes }); |
| 248 | }); |
| 249 | |
| 250 | const URL = `${APP}/guide/interface.html`; |
| 251 | |
| 252 | /// Put the page into one of the app's palettes, the way guide/frame.js does. |
| 253 | const TONES = { light: ['light', 'dark'], dark: ['dark', 'light'] }; |
| 254 | async function wear(theme) { |
| 255 | await page.evaluate(({ t, m }) => { |
| 256 | const r = document.documentElement; |
| 257 | r.setAttribute('data-theme', t); |
| 258 | r.setAttribute('data-tone', m[0]); |
| 259 | r.setAttribute('data-ink', m[1]); |
| 260 | }, { t: theme, m: TONES[theme] }); |
| 261 | await page.waitForTimeout(250); |
| 262 | } |
| 263 | |
| 264 | /// The rendered height of the smallest label in the region map, in CSS pixels |
| 265 | /// on the page as the reader has it. `<text>` only: a rectangle has no words in |
| 266 | /// it, and a font-size in the markup says nothing once the SVG has been scaled. |
| 267 | const measure = () => page.evaluate(() => { |
| 268 | const labs = [...document.querySelectorAll('.regionmap text')]; |
| 269 | if (!labs.length) return null; |
| 270 | const rows = labs.map((l) => ({ |
| 271 | t: l.textContent.trim().slice(0, 24), |
| 272 | h: l.getBoundingClientRect().height, |
| 273 | px: Number(getComputedStyle(l).fontSize.replace('px', '')), |
| 274 | })).sort((a, b) => a.h - b.h); |
| 275 | return { min: rows[0], n: rows.length, all: rows.slice(0, 4) }; |
| 276 | }); |
| 277 | |
| 278 | // ── Desktop, both palettes ─────────────────────────────────────────── |
| 279 | for (const theme of ['light', 'dark']) { |
| 280 | await page.setViewportSize({ width: 1100, height: 900 }); |
| 281 | await page.goto(URL, { waitUntil: 'load' }); |
| 282 | await page.waitForTimeout(400); |
| 283 | await wear(theme); |
| 284 | const m = await measure(); |
| 285 | console.log(` 1100px ${theme.padEnd(5)} smallest label ${m ? m.min.h.toFixed(1) + 'px (' + JSON.stringify(m.min.t) + ', font ' + m.min.px.toFixed(1) + 'px)' : 'none'}`); |
| 286 | const fig = await page.$('.diagram'); |
| 287 | if (fig) await fig.screenshot({ path: path.join(SHOTS, tag(`1100-${theme}`)) }); |
| 288 | } |
| 289 | |
| 290 | // ── 360px, the narrowest width the guide supports ──────────────────── |
| 291 | { |
| 292 | // Eight pixels of rendered ink is the floor dev/verify_vocabulary.mjs holds |
| 293 | // the other diagram to: below that the strokes of a lower-case letter merge |
| 294 | // at this weight. Kept identical so the two figures cannot drift apart. |
| 295 | const FLOOR_PX = 8; |
| 296 | for (const theme of ['light', 'dark']) { |
| 297 | await page.setViewportSize({ width: 360, height: 900 }); |
| 298 | await page.goto(URL, { waitUntil: 'load' }); |
| 299 | await page.waitForTimeout(400); |
| 300 | await wear(theme); |
| 301 | const m = await measure(); |
| 302 | console.log(` 360px ${theme.padEnd(5)} smallest label ${m ? m.min.h.toFixed(1) + 'px (' + JSON.stringify(m.min.t) + ', font ' + m.min.px.toFixed(1) + 'px)' : 'none'}`); |
| 303 | if (theme === 'light') { |
| 304 | const wide = await page.evaluate(() => |
| 305 | document.documentElement.scrollWidth - document.documentElement.clientWidth); |
| 306 | check('the page does not scroll sideways at 360px', wide <= 1, `${wide}px of overflow`); |
| 307 | // The figure carries the scrolling instead, which is the whole trick: |
| 308 | // a floor with no overflow box would simply widen the page. |
| 309 | const inside = await page.evaluate(() => { |
| 310 | const f = document.querySelector('.diagram'); |
| 311 | if (!f) return null; |
| 312 | return { scroll: f.scrollWidth, client: f.clientWidth, |
| 313 | overflow: getComputedStyle(f).overflowX }; |
| 314 | }); |
| 315 | check('the figure scrolls inside its own box', |
| 316 | !!inside && inside.overflow === 'auto' && inside.scroll > inside.client + 1, |
| 317 | inside ? `overflow-x: ${inside.overflow}, ${inside.scroll} in ${inside.client}` : 'no figure'); |
| 318 | check('the region map\'s labels are still legible at 360px', |
| 319 | !!m && m.min.h >= FLOOR_PX, |
| 320 | m ? `smallest label renders ${m.min.h.toFixed(1)}px tall (${JSON.stringify(m.min.t)})` : 'no labels found'); |
| 321 | } |
| 322 | await page.evaluate(() => window.scrollTo(0, 0)); |
| 323 | await page.screenshot({ path: path.join(SHOTS, tag(`360-${theme}`)), fullPage: false }); |
| 324 | } |
| 325 | } |
| 326 | |
| 327 | check('the page threw nothing', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 328 | |
| 329 | await browser.close(); |
| 330 | |
| 331 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 332 | if (BREAK) { |
| 333 | if (bad.length) { console.log('the break was caught, as it should be'); process.exit(0); } |
| 334 | console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing'); |
| 335 | process.exit(1); |
| 336 | } |
| 337 | process.exit(bad.length ? 1 : 0); |