oxedyne/daimond/dev/verify_vocabulary.mjs
31.8 KiB, 1 run
created by r2519314175:793, 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_vocabulary.mjs — the guide's Social page says true things, in words the |
| 2 | // app itself uses, and lands where a deep link points it. |
| 3 | // |
| 4 | // The page exists so that a user reporting a fault and a person reading the |
| 5 | // report use the same word for the same thing. That only works if the words are |
| 6 | // the APP'S words, so the checks below are mostly checks against the app rather |
| 7 | // than against the page's own internal consistency. A glossary that agrees with |
| 8 | // itself and disagrees with the interface is worse than none: it teaches a |
| 9 | // vocabulary that will not be understood. |
| 10 | // |
| 11 | // Ten properties: |
| 12 | // |
| 13 | // 0. THE PAGE DOES NOT SCROLL SIDEWAYS ON A PHONE. A guide page a phone |
| 14 | // reader has to drag left and right is a guide page they fight. Measured |
| 15 | // as the document's own overflow at 360px, which is what the reader's |
| 16 | // thumb feels, and not as any one element's width. |
| 17 | // |
| 18 | // 1. EVERY TERM IS THE APP'S OWN WORD. Each glossary entry names a piece of |
| 19 | // evidence — a string in `www/i18n/en.js`, or a sentence already in another |
| 20 | // guide page — and that evidence has to be findable. This is the check that |
| 21 | // would catch a coined word, which is the one failure that makes the page |
| 22 | // actively harmful. |
| 23 | // |
| 24 | // 2. NO CROP IS BLANK. A crop is taken by selector from the running app, and a |
| 25 | // selector that matches an element which is on the page but invisible |
| 26 | // produces a rectangle of one flat colour. That happened during this page's |
| 27 | // own making: `.attach-btn` is `visibility: hidden` until its row is |
| 28 | // hovered, and the first paperclip crop was a black square. Measured by |
| 29 | // counting distinct colours, not by file size. |
| 30 | // |
| 31 | // 3. THE DIAGRAM IS LEGIBLE IN BOTH A LIGHT AND A DARK PALETTE. It is drawn in |
| 32 | // the palette's variables so it can follow the reader, and the whole point |
| 33 | // of that is lost if the labels vanish on one of them. The label colour is |
| 34 | // sampled against the surface it is drawn on, in both, and held to the |
| 35 | // contrast the app's own audit uses. |
| 36 | // |
| 37 | // 4. A DEEP LINK LANDS ON ITS SECTION, ON A DESKTOP AND ON A PHONE. The |
| 38 | // Improve panel is to carry a button that opens this page at a named |
| 39 | // anchor, so the anchors are an interface and not an implementation |
| 40 | // detail. Each is navigated to, and the section has to end up BELOW the |
| 41 | // sticky header rather than under it. The header GROWS after the first |
| 42 | // landing, at every width, because search.js builds its box and appends |
| 43 | // it: measured against the code before this page, every anchor landed |
| 44 | // about 32px under the header and stayed there. Both widths, because the |
| 45 | // header wraps to four rows on a phone and the failure is larger. |
| 46 | // |
| 47 | // 5. THE ANCHORS SURVIVE THE INDEX BUILD. `dev/guide-index.mjs` renumbers |
| 48 | // every h2 and h3 to a positional id, so an id written on a heading is |
| 49 | // erased the next time the index is built. The page puts them on section |
| 50 | // wrappers instead; this asserts that they are still there afterwards. |
| 51 | // |
| 52 | // 6. THE PAGE OBSERVES THE HOUSE PUNCTUATION. No em dash, no en dash, no |
| 53 | // double hyphen in its prose. |
| 54 | // |
| 55 | // 7. THE DIAGRAM'S WORDS ARE STILL WORDS ON A PHONE. Scaled to a 360px |
| 56 | // column the whole schematic put its labels at five pixels. Measured as |
| 57 | // RENDERED INK — the height of a label's own box at that width — and not |
| 58 | // as a font-size in the stylesheet, which says nothing once an SVG has |
| 59 | // been scaled to fit. |
| 60 | // |
| 61 | // 8. THE LANGUAGE SWITCHER OFFERS ONLY PAGES THAT EXIST. `data-guide-locales` |
| 62 | // is what frame.js reads to decide whether a change of language means |
| 63 | // going anywhere, so a locale named there and not on disk is a 404 the |
| 64 | // reader is walked into. It is checked both ways: nothing promised that is |
| 65 | // missing, and nothing on disk that is not offered. |
| 66 | // |
| 67 | // 9. AND NO TRANSLATION SCROLLS SIDEWAYS EITHER. Property 0 measures the |
| 68 | // English page, and the English page is the shortest. The same quoted |
| 69 | // sentence runs 211 pixels past the edge in French, and every translated |
| 70 | // copy spills on BOTH quotations where English spills on one, so a fix |
| 71 | // proved against English alone is a fix proved against the easy case. |
| 72 | // |
| 73 | // EACH CHECK IS PROVED AGAINST A BROKEN PAGE FIRST. `--break <name>` damages a |
| 74 | // copy of a file and serves it to the real browser through `page.route`, or |
| 75 | // damages the input a static check reads, and the run is expected to FAIL. A |
| 76 | // break that does not apply cleanly aborts rather than passing quietly. |
| 77 | // |
| 78 | // node dev/verify_vocabulary.mjs --break coined # 1 fails |
| 79 | // node dev/verify_vocabulary.mjs --break blankcrop # 2 fails |
| 80 | // node dev/verify_vocabulary.mjs --break invisible # 3 fails |
| 81 | // node dev/verify_vocabulary.mjs --break nomargin # 4 fails |
| 82 | // node dev/verify_vocabulary.mjs --break heading # 5 fails |
| 83 | // node dev/verify_vocabulary.mjs --break dash # 6 fails |
| 84 | // node dev/verify_vocabulary.mjs --break tinylabels # 7 fails |
| 85 | // node dev/verify_vocabulary.mjs --break nolanding # 4 fails, at 360px only |
| 86 | // node dev/verify_vocabulary.mjs --break sideways # 0 fails |
| 87 | // node dev/verify_vocabulary.mjs --break promise # 8 fails, on the half |
| 88 | // node dev/verify_vocabulary.mjs --break unlisted # 8 fails, on the other |
| 89 | // node dev/verify_vocabulary.mjs --break sideloc # 9 fails, and 0 does not |
| 90 | // node dev/verify_vocabulary.mjs # and then, clean |
| 91 | // |
| 92 | // eval "$(bash dev/world.sh 6 --up)" |
| 93 | // node dev/verify_vocabulary.mjs |
| 94 | // |
| 95 | // Needs dev/serve.mjs only: the guide is flat files and loads none of the app. |
| 96 | // Writes its screenshots to dev/shots/vocab-page-*.png. |
| 97 | import fs from 'node:fs'; |
| 98 | import os from 'node:os'; |
| 99 | import path from 'node:path'; |
| 100 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 101 | |
| 102 | // Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever |
| 103 | // `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed |
| 104 | // run under `xvfb-run` still went to the compositor and opened a window on the owner's |
| 105 | // desktop. Importing this strips the two variables from `process.env`, which is all a |
| 106 | // launcher that spreads `process.env` needs. See dev/display.mjs. |
| 107 | import './display.mjs'; |
| 108 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 109 | const WWW = path.join(HERE, '..', 'www'); |
| 110 | const GUIDE = path.join(WWW, 'guide'); |
| 111 | const PAGE = path.join(GUIDE, 'social.html'); |
| 112 | const SHOTS = path.join(HERE, 'shots'); |
| 113 | const APP = process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`; |
| 114 | const PW = process.env.DAIMOND_PW |
| 115 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 116 | const CHROME = process.env.DAIMOND_CHROME |
| 117 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 118 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 119 | |
| 120 | const BREAK = (() => { |
| 121 | const i = process.argv.indexOf('--break'); |
| 122 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 123 | })(); |
| 124 | |
| 125 | const ok = [], bad = []; |
| 126 | const check = (name, pass, detail) => { |
| 127 | (pass ? ok : bad).push(name); |
| 128 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 129 | }; |
| 130 | const die = (why) => { console.error('ABORT: ' + why); process.exit(2); }; |
| 131 | |
| 132 | // ── What the page claims, and where the app says it ────────────────── |
| 133 | // |
| 134 | // One row per glossary entry: the anchor it sits at, the word, and a string that |
| 135 | // has to appear in the app's own English catalogue or in another guide page. The |
| 136 | // evidence is deliberately a WHOLE PHRASE and not the bare word, so that a term |
| 137 | // cannot be justified by a coincidence in a comment. |
| 138 | const TERMS = [ |
| 139 | ['term-chip', 'chip', 'i18n', "so the chip would refuse every turn"], |
| 140 | ['term-tile', 'tile', 'i18n', "'tile.settings': 'Settings for this tile'"], |
| 141 | ['term-head', 'head', 'guide', 'on the Diamonds head'], |
| 142 | ['term-closer', 'closer', 'guide', "rail's own closer"], |
| 143 | ['term-cog', 'cog', 'guide', "tile's own cog"], |
| 144 | ['term-light', 'light', 'guide', 'Its light is the root of every other'], |
| 145 | ['term-divider', 'divider', 'guide', 'divider you can drag'], |
| 146 | ['term-row', 'row', 'guide', 'row in the admin panel'], |
| 147 | ['term-spend-row', 'spend row', 'i18n', "The three cells of the rail's spend row"], |
| 148 | ['term-composer', 'composer', 'guide', 'the composer stays put'], |
| 149 | ['term-face', 'face', 'i18n', "'Which face of this Diamond'"], |
| 150 | ['term-tag', 'tag', 'i18n', "'tag.pool_toggle': 'Filter by tag ({n})'"], |
| 151 | ['term-dialog', 'dialog', 'i18n', "'dlg.are_you_sure': 'Are you sure?'"], |
| 152 | ['term-gallery', 'gallery', 'guide', 'opens the <strong>gallery</strong>'], |
| 153 | ['term-goto', 'Go to box', 'i18n', "'pal.close': 'Close the Go to box'"], |
| 154 | ['term-sheet', 'sheet', 'i18n', "'sheet.close': 'Close the sheet'"], |
| 155 | ['term-drawer', 'drawer', 'guide', 'the rail becomes a drawer'], |
| 156 | ['term-paperclip', 'paperclip', 'guide', 'header carries the paperclip'], |
| 157 | ]; |
| 158 | |
| 159 | /// Walk the page top to bottom, so every `loading="lazy"` crop has pixels |
| 160 | /// before a full-page screenshot is taken of it. |
| 161 | async function unlazy(pg) { |
| 162 | await pg.evaluate(async () => { |
| 163 | const step = window.innerHeight * 0.8; |
| 164 | for (let y = 0; y < document.body.scrollHeight; y += step) { |
| 165 | window.scrollTo(0, y); |
| 166 | await new Promise((r) => setTimeout(r, 120)); |
| 167 | } |
| 168 | window.scrollTo(0, 0); |
| 169 | await new Promise((r) => setTimeout(r, 250)); |
| 170 | }); |
| 171 | } |
| 172 | |
| 173 | /// The sections a deep link may name. These are a published interface: the |
| 174 | /// Improve panel's own button opens the guide at one of them. |
| 175 | const ANCHORS = ['writing-a-note', 'regions', 'glossary', 'two-words', 'social-panel']; |
| 176 | |
| 177 | // ── The breaks ─────────────────────────────────────────────────────── |
| 178 | // |
| 179 | // Each returns the bytes to serve in place of a real file, or mutates the input |
| 180 | // a static check reads. Nothing on disk is touched. |
| 181 | let pageBytes = fs.readFileSync(PAGE, 'utf8'); |
| 182 | let en = fs.readFileSync(path.join(WWW, 'i18n', 'en.js'), 'utf8'); |
| 183 | let guideText = fs.readdirSync(GUIDE).filter((f) => f.endsWith('.html') && f !== 'social.html') |
| 184 | .map((f) => fs.readFileSync(path.join(GUIDE, f), 'utf8')).join('\n'); |
| 185 | let blankCrop = null; // a crop name to replace with a flat rectangle |
| 186 | |
| 187 | /// The locale folders that actually hold a Social page. A folder is a locale, |
| 188 | /// not `legal/` or `img/`, on the same shape guide-index.mjs uses. |
| 189 | const onDiskLocales = fs.readdirSync(GUIDE, { withFileTypes: true }) |
| 190 | .filter((e) => e.isDirectory() && /^[a-z]{2}(-[A-Za-z]+)?$/.test(e.name)) |
| 191 | .map((e) => e.name) |
| 192 | .filter((l) => fs.existsSync(path.join(GUIDE, l, 'social.html'))) |
| 193 | .sort(); |
| 194 | |
| 195 | /// Each translated page's bytes, by locale, so a break can damage them in |
| 196 | /// flight the way it damages the English page. |
| 197 | const localeBytes = {}; |
| 198 | for (const l of onDiskLocales) localeBytes[l] = fs.readFileSync(path.join(GUIDE, l, 'social.html'), 'utf8'); |
| 199 | |
| 200 | const applied = []; |
| 201 | switch (BREAK) { |
| 202 | case '': break; |
| 203 | case 'coined': { |
| 204 | // A term with no evidence anywhere: the failure this page exists to avoid. |
| 205 | TERMS.push(['term-widget', 'widget', 'i18n', "'widget.name': 'Widget'"]); |
| 206 | applied.push('added a coined term with no evidence'); |
| 207 | break; |
| 208 | } |
| 209 | case 'blankcrop': { |
| 210 | blankCrop = 'vocab-paperclip.png'; |
| 211 | applied.push('served a flat rectangle for ' + blankCrop); |
| 212 | break; |
| 213 | } |
| 214 | case 'invisible': { |
| 215 | // The diagram's labels drawn in the surface colour they sit on: legible |
| 216 | // on neither palette, and exactly what a hard-coded colour would do on |
| 217 | // one of the two. |
| 218 | const before = pageBytes; |
| 219 | pageBytes = pageBytes.replace('.wm-lab { fill: var(--accent-text); font-weight: 700; }', |
| 220 | '.wm-lab { fill: var(--bg-primary); font-weight: 700; }'); |
| 221 | if (pageBytes === before) die('the invisible break did not apply'); |
| 222 | applied.push('drew the diagram labels in the background colour'); |
| 223 | break; |
| 224 | } |
| 225 | case 'nomargin': { |
| 226 | // frame.js installs the rule that keeps a jump clear of the sticky |
| 227 | // header. Without it a deep link lands with its section under the header. |
| 228 | applied.push('removed the scroll-margin rule frame.js installs'); |
| 229 | break; |
| 230 | } |
| 231 | case 'heading': { |
| 232 | // The ids moved onto the headings, where dev/guide-index.mjs erases them |
| 233 | // on its next run. Simulated by taking them off the sections. |
| 234 | const before = pageBytes; |
| 235 | for (const a of ANCHORS) pageBytes = pageBytes.replace(`<section id="${a}">`, '<section>'); |
| 236 | if (pageBytes === before) die('the heading break did not apply'); |
| 237 | applied.push('moved the section anchors off the sections'); |
| 238 | break; |
| 239 | } |
| 240 | case 'nolanding': { |
| 241 | applied.push('took the re-landing out of frame.js'); |
| 242 | break; |
| 243 | } |
| 244 | case 'tinylabels': { |
| 245 | // The width floor removed, so the diagram is scaled to the phone column |
| 246 | // and its labels go with it. |
| 247 | const before = pageBytes; |
| 248 | pageBytes = pageBytes.replace('.diagram.scrolls svg { min-width: 640px; }', ''); |
| 249 | if (pageBytes === before) die('the tinylabels break did not apply'); |
| 250 | applied.push('let the diagram scale down to the phone column'); |
| 251 | break; |
| 252 | } |
| 253 | case 'dash': { |
| 254 | const before = pageBytes; |
| 255 | pageBytes = pageBytes.replace('<h1>Social</h1>', '<h1>Social — the vocabulary</h1>'); |
| 256 | if (pageBytes === before) die('the dash break did not apply'); |
| 257 | applied.push('put an em dash in the heading'); |
| 258 | break; |
| 259 | } |
| 260 | case 'sideways': { |
| 261 | // The rule that lets a quoted sentence wrap. Without it the pill in |
| 262 | // guide.css keeps `white-space: nowrap`, the longest quotation runs off |
| 263 | // the right edge, and the page goes sideways under the reader's thumb. |
| 264 | const before = pageBytes; |
| 265 | pageBytes = pageBytes.replace('.ui.quoted { white-space: normal; }', ''); |
| 266 | if (pageBytes === before) die('the sideways break did not apply'); |
| 267 | applied.push('took the wrapping rule off the quoted app sentences'); |
| 268 | break; |
| 269 | } |
| 270 | case 'promise': { |
| 271 | // A locale offered that was never written: the language switcher walks |
| 272 | // the reader into a 404, which is what an eight-locale declaration over |
| 273 | // five pages did. |
| 274 | const before = pageBytes; |
| 275 | pageBytes = pageBytes.replace(/data-guide-locales="([^"]*)"/, (m, list) => { |
| 276 | const gone = ['ja', 'ko', 'zh-Hans'].find((l) => !list.split(' ').includes(l)); |
| 277 | if (!gone) die('the promise break found every locale already declared'); |
| 278 | return `data-guide-locales="${list} ${gone}"`; |
| 279 | }); |
| 280 | if (pageBytes === before) die('the promise break did not apply'); |
| 281 | applied.push('promised a translation that is not on disk'); |
| 282 | break; |
| 283 | } |
| 284 | case 'unlisted': { |
| 285 | // The other half: a page that exists and is never offered, so a reader |
| 286 | // in that language is left on English with no way across. |
| 287 | if (!onDiskLocales.length) die('the unlisted break has no translation to hide'); |
| 288 | const hide = onDiskLocales[onDiskLocales.length - 1]; |
| 289 | const before = pageBytes; |
| 290 | pageBytes = pageBytes.replace(/data-guide-locales="([^"]*)"/, |
| 291 | (m, list) => `data-guide-locales="${list.split(' ').filter((l) => l !== hide).join(' ')}"`); |
| 292 | if (pageBytes === before) die('the unlisted break did not apply'); |
| 293 | applied.push(`stopped offering ${hide}, which is on disk`); |
| 294 | break; |
| 295 | } |
| 296 | case 'sideloc': { |
| 297 | // The wrapping rule taken off the TRANSLATIONS and left on the English |
| 298 | // page, so property 0 stays green and only property 9 goes red. |
| 299 | let hit = 0; |
| 300 | for (const l of onDiskLocales) { |
| 301 | const before = localeBytes[l]; |
| 302 | localeBytes[l] = before.replace('.ui.quoted { white-space: normal; }', ''); |
| 303 | if (localeBytes[l] !== before) hit++; |
| 304 | } |
| 305 | if (!hit) die('the sideloc break reached no translated page'); |
| 306 | applied.push(`took the wrapping rule off ${hit} translated page(s)`); |
| 307 | break; |
| 308 | } |
| 309 | default: die(`no break called "${BREAK}"`); |
| 310 | } |
| 311 | if (BREAK) console.log(`BREAK ${BREAK}: ${applied.join('; ')}\n`); |
| 312 | |
| 313 | |
| 314 | /// frame.js with the late re-landing taken out, and only that: the reader is |
| 315 | /// put on their anchor once, before the search box has been built, exactly as |
| 316 | /// the file behaved before this page needed it to be right on a phone. Both |
| 317 | /// halves have to come out, and each is required to apply, because a break that |
| 318 | /// half-lands is a break that proves half a check. |
| 319 | function nolanding(src) { |
| 320 | let out = src.replace("\t\tif (!landed || touched) return;", "\t\treturn;"); |
| 321 | if (out === src) die('the nolanding break did not reach settleLanding'); |
| 322 | const later = out; |
| 323 | out = out.replace(/\n\t\t\twindow\.addEventListener\('load'[\s\S]*?\}, 1000\);/, ''); |
| 324 | if (out === later) die('the nolanding break did not reach the later measurements'); |
| 325 | return out; |
| 326 | } |
| 327 | |
| 328 | // ── 1. Every term is the app's own word ────────────────────────────── |
| 329 | { |
| 330 | const missing = []; |
| 331 | for (const [id, word, where, evidence] of TERMS) { |
| 332 | const hay = where === 'i18n' ? en : guideText; |
| 333 | if (!hay.includes(evidence)) missing.push(`${word} (${where})`); |
| 334 | if (!pageBytes.includes(`id="${id}"`)) missing.push(`${word}: no entry at #${id}`); |
| 335 | } |
| 336 | check('every glossary term is a word the app or the guide already uses', |
| 337 | missing.length === 0, missing.join(', ')); |
| 338 | } |
| 339 | |
| 340 | // ── 6. House punctuation ───────────────────────────────────────────── |
| 341 | { |
| 342 | // The article only. The SVG's path data uses hyphens freely, a hyphen inside |
| 343 | // a word is not a dash, and the document title carries the guide's own |
| 344 | // "Improving Daimond — Daimond guide" pattern, which every page has and which |
| 345 | // is not this page's prose to change. |
| 346 | const main = (pageBytes.match(/<main[^>]*>([\s\S]*?)<\/main>/i) || [, ''])[1]; |
| 347 | const prose = main.replace(/<svg[\s\S]*?<\/svg>/gi, ' '); |
| 348 | const hits = []; |
| 349 | for (const [re, what] of [[/—/g, 'em dash'], [/–/g, 'en dash'], [/\s--\s/g, 'double hyphen']]) { |
| 350 | const m = prose.match(re); |
| 351 | if (m) hits.push(`${m.length} ${what}`); |
| 352 | } |
| 353 | check('no dashes in the page\'s prose', hits.length === 0, hits.join(', ')); |
| 354 | } |
| 355 | |
| 356 | // ── 5. The anchors survive the index build ─────────────────────────── |
| 357 | { |
| 358 | // Read from disk, not from the possibly-broken copy: this is a fact about the |
| 359 | // file the index generator writes, and `--break heading` proves it by taking |
| 360 | // the ids off, which is what the generator's renumbering would do to ids |
| 361 | // written on the headings instead. |
| 362 | const onDisk = BREAK === 'heading' ? pageBytes : fs.readFileSync(PAGE, 'utf8'); |
| 363 | const gone = ANCHORS.filter((a) => !new RegExp(`<section[^>]*\\sid="${a}"`).test(onDisk)); |
| 364 | // And the headings themselves must carry the positional ids, which is what |
| 365 | // says the index has been built over this page at all. |
| 366 | const positional = (onDisk.match(/<h[23] id="s\d+"/g) || []).length; |
| 367 | const pass = gone.length === 0 && positional > 0; |
| 368 | check('the section anchors survive dev/guide-index.mjs', pass, pass ? '' |
| 369 | : gone.length ? `missing: ${gone.join(', ')}` |
| 370 | : 'the index has never been built over this page'); |
| 371 | } |
| 372 | |
| 373 | // ── 8. The switcher offers only pages that exist ───────────────────── |
| 374 | { |
| 375 | // Read from `pageBytes`, so `--break promise` and `--break unlisted` are |
| 376 | // seen; the disk side is read from the disk, because that is the fact the |
| 377 | // declaration is being held against. |
| 378 | const promised = ((pageBytes.match(/data-guide-locales="([^"]*)"/) || [, ''])[1]) |
| 379 | .split(' ').filter((l) => l && l !== 'en'); |
| 380 | const missing = promised.filter((l) => !fs.existsSync(path.join(GUIDE, l, 'social.html'))); |
| 381 | const unlisted = onDiskLocales.filter((l) => !promised.includes(l)); |
| 382 | const why = []; |
| 383 | if (missing.length) why.push(`offered with no page: ${missing.join(', ')}`); |
| 384 | if (unlisted.length) why.push(`on disk and never offered: ${unlisted.join(', ')}`); |
| 385 | check('the language switcher offers exactly the translations that exist', |
| 386 | why.length === 0, why.join('; ') || `${promised.length} translation(s)`); |
| 387 | } |
| 388 | |
| 389 | // ── The browser ────────────────────────────────────────────────────── |
| 390 | const { chromium } = await import(pathToFileURL(PW).href); |
| 391 | const profile = path.join(SCRATCH, 'pw', 'vocabulary' + (BREAK ? '-' + BREAK : '')); |
| 392 | fs.rmSync(profile, { recursive: true, force: true }); |
| 393 | fs.mkdirSync(profile, { recursive: true }); |
| 394 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 395 | |
| 396 | // A forwarded DISPLAY means no compositor frames, so requestAnimationFrame never |
| 397 | // fires and every wait hangs. See dev/harness.mjs. |
| 398 | const env = { ...process.env }; |
| 399 | delete env.DISPLAY; |
| 400 | |
| 401 | const browser = await chromium.launchPersistentContext(profile, { |
| 402 | executablePath: CHROME, |
| 403 | headless: false, |
| 404 | args: ['--no-sandbox', '--disable-dev-shm-usage', '--headless=new'], |
| 405 | env, |
| 406 | viewport: { width: 1100, height: 900 }, |
| 407 | }); |
| 408 | const page = browser.pages()[0] || await browser.newPage(); |
| 409 | const errs = []; |
| 410 | page.on('pageerror', (e) => errs.push(String(e.message))); |
| 411 | page.on('console', (m) => { if (m.type() === 'error') errs.push(m.text()); }); |
| 412 | |
| 413 | await page.route('**/guide/social.html*', (route) => { |
| 414 | route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: pageBytes }); |
| 415 | }); |
| 416 | for (const l of onDiskLocales) { |
| 417 | await page.route(`**/guide/${l}/social.html*`, (route) => { |
| 418 | route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: localeBytes[l] }); |
| 419 | }); |
| 420 | } |
| 421 | if (blankCrop) { |
| 422 | // A one-colour PNG, which is what a crop of an invisible element looks like. |
| 423 | const flat = Buffer.from( |
| 424 | 'iVBORw0KGgoAAAANSUhEUgAAAGQAAAAyCAYAAACqNX6+AAAAM0lEQVR4nO3BAQEAAACCIP+vbkhA' |
| 425 | + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgNxFAAAHrqBWzAAAAAElFTkSuQmCC', 'base64'); |
| 426 | await page.route(`**/guide/shots/${blankCrop}`, (route) => { |
| 427 | route.fulfill({ status: 200, contentType: 'image/png', body: flat }); |
| 428 | }); |
| 429 | } |
| 430 | if (BREAK === 'nomargin' || BREAK === 'nolanding') { |
| 431 | await page.route('**/guide/frame.js', async (route) => { |
| 432 | const src = fs.readFileSync(path.join(GUIDE, 'frame.js'), 'utf8'); |
| 433 | const hurt = BREAK === 'nomargin' |
| 434 | ? src.replace("rule.textContent = 'main [id] { scroll-margin-top: var(--guide-head-h, 7rem); }';", |
| 435 | "rule.textContent = '';") |
| 436 | : nolanding(src); |
| 437 | if (hurt === src) die(`the ${BREAK} break did not apply`); |
| 438 | route.fulfill({ status: 200, contentType: 'text/javascript', body: hurt }); |
| 439 | }); |
| 440 | } |
| 441 | |
| 442 | const URL = `${APP}/guide/social.html`; |
| 443 | await page.goto(URL, { waitUntil: 'load' }); |
| 444 | await page.waitForTimeout(600); |
| 445 | |
| 446 | // ── 2. No crop is blank ────────────────────────────────────────────── |
| 447 | { |
| 448 | // Counted in the browser, from the image as it was actually decoded, so a |
| 449 | // file that is fine on disk and 404s on the way in also fails. |
| 450 | // Fetched again into images of our own rather than read off the page's. The |
| 451 | // page's are `loading="lazy"`, so most of them have no pixels at all until |
| 452 | // they are scrolled past, and `decode()` on one in that state took the whole |
| 453 | // renderer down. A fresh Image also fails loudly on a src that 404s, which is |
| 454 | // half of what this check is for. |
| 455 | const flat = await page.evaluate(async () => { |
| 456 | const out = []; |
| 457 | const srcs = [...document.querySelectorAll('.term img')].map((i) => i.getAttribute('src')); |
| 458 | for (const src of srcs) { |
| 459 | const img = new Image(); |
| 460 | img.src = src; |
| 461 | try { await img.decode(); } catch (e) { out.push({ src, why: 'did not load' }); continue; } |
| 462 | const w = img.naturalWidth, h = img.naturalHeight; |
| 463 | if (!w || !h) { out.push({ src, why: 'no pixels' }); continue; } |
| 464 | const c = document.createElement('canvas'); |
| 465 | c.width = w; c.height = h; |
| 466 | c.getContext('2d').drawImage(img, 0, 0); |
| 467 | const d = c.getContext('2d').getImageData(0, 0, w, h).data; |
| 468 | const seen = new Set(); |
| 469 | for (let i = 0; i < d.length; i += 4) { |
| 470 | seen.add((d[i] >> 3) + ',' + (d[i + 1] >> 3) + ',' + (d[i + 2] >> 3)); |
| 471 | if (seen.size > 12) break; |
| 472 | } |
| 473 | if (seen.size <= 3) out.push({ src, why: `${seen.size} colours` }); |
| 474 | } |
| 475 | return out; |
| 476 | }); |
| 477 | check('no glossary crop is a flat rectangle', flat.length === 0, |
| 478 | flat.map((f) => `${f.src}: ${f.why}`).join(', ')); |
| 479 | } |
| 480 | |
| 481 | // ── 3. The diagram is legible on a light palette and a dark one ────── |
| 482 | { |
| 483 | // The contrast the app's own theme audit holds ink to against a surface. |
| 484 | const FLOOR = 3.0; |
| 485 | const lum = (rgb) => { |
| 486 | const f = rgb.map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); |
| 487 | return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; |
| 488 | }; |
| 489 | const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; |
| 490 | const parse = (s) => (s.match(/[\d.]+/g) || []).slice(0, 3).map(Number); |
| 491 | |
| 492 | const results = []; |
| 493 | for (const theme of ['light', 'dark']) { |
| 494 | await page.evaluate((t) => { |
| 495 | // The same three attributes guide/frame.js sets when the app tells it |
| 496 | // which palette to wear. |
| 497 | const map = { light: ['light', 'dark'], dark: ['dark', 'light'] }; |
| 498 | document.documentElement.setAttribute('data-theme', t); |
| 499 | document.documentElement.setAttribute('data-tone', map[t][0]); |
| 500 | document.documentElement.setAttribute('data-ink', map[t][1]); |
| 501 | }, theme); |
| 502 | await page.waitForTimeout(300); |
| 503 | const pair = await page.evaluate(() => { |
| 504 | const lab = document.querySelector('.widgetmap .wm-lab'); |
| 505 | const pan = document.querySelector('.widgetmap .wm-pan'); |
| 506 | const win = document.querySelector('.widgetmap .wm-win'); |
| 507 | if (!lab || !pan || !win) return null; |
| 508 | return { |
| 509 | ink: getComputedStyle(lab).fill, |
| 510 | pan: getComputedStyle(pan).fill, |
| 511 | win: getComputedStyle(win).fill, |
| 512 | }; |
| 513 | }); |
| 514 | if (!pair) { results.push(`${theme}: the diagram is not on the page`); continue; } |
| 515 | const r1 = ratio(parse(pair.ink), parse(pair.pan)); |
| 516 | const r2 = ratio(parse(pair.ink), parse(pair.win)); |
| 517 | const worst = Math.min(r1, r2); |
| 518 | if (worst < FLOOR) results.push(`${theme}: labels at ${worst.toFixed(2)}:1`); |
| 519 | await unlazy(page); |
| 520 | await page.screenshot({ path: path.join(SHOTS, `vocab-page-${theme}.png`), fullPage: true }); |
| 521 | } |
| 522 | check('the zone diagram\'s labels are legible on a light palette and a dark one', |
| 523 | results.length === 0, results.join(', ')); |
| 524 | } |
| 525 | |
| 526 | // ── 4. A deep link lands on its section, at both widths ────────────── |
| 527 | { |
| 528 | const under = []; |
| 529 | for (const [w, h] of [[1100, 900], [360, 800]]) { |
| 530 | await page.setViewportSize({ width: w, height: h }); |
| 531 | for (const a of ANCHORS) { |
| 532 | // A fresh load per anchor, which is what the Improve panel's button |
| 533 | // will do: it sets the frame's src, it does not click a link on a page |
| 534 | // already scrolled somewhere. |
| 535 | // |
| 536 | // Through `about:blank`, because `goto` from `#a` to `#b` on one URL |
| 537 | // is a SAME-DOCUMENT navigation: the page is never reloaded, frame.js |
| 538 | // never runs again, and four of the five anchors were being checked |
| 539 | // against a document that had settled minutes earlier. |
| 540 | await page.goto('about:blank'); |
| 541 | await page.goto(`${URL}#${a}`, { waitUntil: 'load' }); |
| 542 | await page.waitForTimeout(1500); |
| 543 | const r = await page.evaluate((id) => { |
| 544 | const el = document.getElementById(id); |
| 545 | if (!el) return null; |
| 546 | const head = document.querySelector('.site-head'); |
| 547 | const hb = head ? head.getBoundingClientRect().bottom : 0; |
| 548 | // The heading inside the section is what the reader has to see. |
| 549 | const hh = el.querySelector('h2') || el; |
| 550 | const box = hh.getBoundingClientRect(); |
| 551 | return { top: box.top, headBottom: hb, y: window.scrollY }; |
| 552 | }, a); |
| 553 | if (!r) { under.push(`${w}px: #${a} is not on the page`); continue; } |
| 554 | if (r.top < r.headBottom) under.push(`${w}px: #${a} landed ${Math.round(r.headBottom - r.top)}px under the header`); |
| 555 | // And it has to have moved at all: an anchor that never scrolls is one |
| 556 | // the browser did not find. |
| 557 | if (a !== ANCHORS[0] && r.y <= 0) under.push(`${w}px: #${a} did not scroll`); |
| 558 | } |
| 559 | } |
| 560 | check('every published anchor lands below the sticky header, at 1100px and at 360px', |
| 561 | under.length === 0, under.join(', ')); |
| 562 | } |
| 563 | |
| 564 | // ── 4b. A header that grows afterwards is landed on again ──────────── |
| 565 | { |
| 566 | // The header GROWS after a reader has landed: `search.js` builds the search |
| 567 | // box and appends it, and on a phone that is a whole extra row. Before this |
| 568 | // page, that left a phone reader's heading 32px under the header for about a |
| 569 | // second, until something later put it right; a jump under the reader's eye |
| 570 | // is a fault whether or not the page ends up correct. |
| 571 | // |
| 572 | // Asserted as a CALL and not as a position. Where the page ends up after the |
| 573 | // header changes size depends on the browser's own scroll anchoring, which |
| 574 | // fires or does not depending on what else the page has been doing, and a |
| 575 | // check resting on that passes and fails at random. What frame.js owes the |
| 576 | // reader is that it lands them again; that is what is counted. |
| 577 | await page.setViewportSize({ width: 360, height: 800 }); |
| 578 | await page.goto('about:blank'); |
| 579 | await page.goto(`${URL}#glossary`, { waitUntil: 'load' }); |
| 580 | await page.waitForTimeout(1200); |
| 581 | const relands = await page.evaluate(async () => { |
| 582 | let n = 0; |
| 583 | const orig = Element.prototype.scrollIntoView; |
| 584 | Element.prototype.scrollIntoView = function () { n++; return orig.apply(this, arguments); }; |
| 585 | const grow = document.createElement('div'); |
| 586 | grow.style.height = '48px'; |
| 587 | document.querySelector('.site-head').appendChild(grow); |
| 588 | await new Promise((r) => setTimeout(r, 400)); |
| 589 | Element.prototype.scrollIntoView = orig; |
| 590 | return n; |
| 591 | }); |
| 592 | check('a header that grows after the landing puts the reader back on their anchor', |
| 593 | relands > 0, `${relands} landings after the header changed size`); |
| 594 | } |
| 595 | |
| 596 | // ── The narrowest screen the guide supports ────────────────────────── |
| 597 | { |
| 598 | await page.setViewportSize({ width: 360, height: 900 }); |
| 599 | await page.goto(URL, { waitUntil: 'load' }); // no hash: the whole page, from the top |
| 600 | await page.waitForTimeout(600); |
| 601 | const wide = await page.evaluate(() => |
| 602 | document.documentElement.scrollWidth - document.documentElement.clientWidth); |
| 603 | await unlazy(page); |
| 604 | await page.screenshot({ path: path.join(SHOTS, 'vocab-page-narrow.png'), fullPage: true }); |
| 605 | check('the page does not scroll sideways at 360px', wide <= 1, `${wide}px of overflow`); |
| 606 | |
| 607 | // ── 7. The diagram's words are still words ─────────────────── |
| 608 | // The smallest label's rendered height, in CSS pixels on the page as the |
| 609 | // reader has it. Eight is the floor: below that the strokes of a lower-case |
| 610 | // letter merge at this weight, which is what the phone render was doing. |
| 611 | const FLOOR_PX = 8; |
| 612 | const ink = await page.evaluate(() => { |
| 613 | const labs = [...document.querySelectorAll('.widgetmap .wm-lab')]; |
| 614 | if (!labs.length) return null; |
| 615 | const hs = labs.map((l) => l.getBoundingClientRect().height); |
| 616 | return { min: Math.min(...hs), n: labs.length }; |
| 617 | }); |
| 618 | check('the diagram\'s labels are still legible at 360px', |
| 619 | !!ink && ink.min >= FLOOR_PX, |
| 620 | ink ? `smallest label renders ${ink.min.toFixed(1)}px tall` : 'no labels found'); |
| 621 | } |
| 622 | |
| 623 | // ── 9. And no translation scrolls sideways either ──────────────────── |
| 624 | { |
| 625 | // Still at 360px from the block above. Each translated copy is loaded in |
| 626 | // turn and measured the same way, because the sentence that spilled is a |
| 627 | // QUOTATION OF THE APP and every language quotes a different one: the French |
| 628 | // `post.audience` is 110 characters where the English is 66. |
| 629 | const wide = []; |
| 630 | for (const l of onDiskLocales) { |
| 631 | await page.goto('about:blank'); |
| 632 | await page.goto(`${APP}/guide/${l}/social.html`, { waitUntil: 'load' }); |
| 633 | await page.waitForTimeout(400); |
| 634 | const over = await page.evaluate(() => |
| 635 | document.documentElement.scrollWidth - document.documentElement.clientWidth); |
| 636 | if (over > 1) wide.push(`${l}: ${over}px`); |
| 637 | } |
| 638 | check('no translation of the page scrolls sideways at 360px either', |
| 639 | wide.length === 0, wide.join(', ') || `${onDiskLocales.length} translation(s) measured`); |
| 640 | } |
| 641 | |
| 642 | check('the page threw nothing', errs.length === 0, errs.slice(0, 3).join(' | ')); |
| 643 | |
| 644 | await browser.close(); |
| 645 | |
| 646 | console.log(`\n${ok.length} ok, ${bad.length} failed`); |
| 647 | if (BREAK) { |
| 648 | if (bad.length) { console.log(`the break was caught, as it should be`); process.exit(0); } |
| 649 | console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing'); |
| 650 | process.exit(1); |
| 651 | } |
| 652 | process.exit(bad.length ? 1 : 0); |