oxedyne/daimond/dev/verify_guidefacts.mjs
24.8 KiB, 1 run
created by r2519314175:453, 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_guidefacts.mjs — the guide says the things a first install cost an hour. |
| 2 | // |
| 3 | // dev/verify_guidemachine.mjs already asks whether the Machine Operations page |
| 4 | // LOOKS right: it renders, it does not scroll sideways, its code blocks keep |
| 5 | // their overflow. This file asks whether it SAYS the right things, which is a |
| 6 | // different failure and one no layout check can see. |
| 7 | // |
| 8 | // Three groups. |
| 9 | // |
| 10 | // The new facts, each of which was learned the expensive way: a snap or |
| 11 | // flatpak browser cannot run this at all, `apt install chromium-browser` is |
| 12 | // how you get one by accident, a browser profile does not exist until the |
| 13 | // browser has been run, the passphrase alone cannot restore an account on a |
| 14 | // fresh browser, and `install.sh --check` is the first thing to run when |
| 15 | // something is wrong. A fact stated below the fold of a long page is a fact |
| 16 | // nobody reads, so the two that STOP a reader are also required to appear |
| 17 | // before the installation steps do. |
| 18 | // |
| 19 | // The old facts, none of which may have been lost in the reordering. This is |
| 20 | // a friendliness pass, not a simplification pass, so the security detail, the |
| 21 | // fence's limits and the "what is not protected" list are all looked for. |
| 22 | // |
| 23 | // The correction. The guide used to say that anyone who imports a backup |
| 24 | // becomes you. That is the claim to keep out, and it is wrong for one reason: |
| 25 | // `doExport` in www/js/daimond.js DOES write the identity -- it calls |
| 26 | // DaimondIdentity.exportBundle() -- but the private key in that bundle is |
| 27 | // sealed under PBKDF2(passphrase, salt), so the file opens to the passphrase |
| 28 | // and to nothing else. Both halves have to be said. Drop the first and a |
| 29 | // reader who deletes a browser after taking a backup loses the account the |
| 30 | // backup was holding; drop the second and the file reads as a bearer token. |
| 31 | // The English page and all seven translations are checked for both. |
| 32 | // |
| 33 | // And the callout the stop-facts sit in has to be legible in all eleven |
| 34 | // palettes, not the two a colorScheme flag reaches. |
| 35 | // |
| 36 | // node dev/verify_guidefacts.mjs the checks |
| 37 | // node dev/verify_guidefacts.mjs --prove each check, against broken text |
| 38 | // |
| 39 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). |
| 40 | import fs from 'node:fs'; |
| 41 | import os from 'node:os'; |
| 42 | import path from 'node:path'; |
| 43 | import { pathToFileURL } from 'node:url'; |
| 44 | |
| 45 | const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 46 | const { chromium } = await import(pathToFileURL(PW).href); |
| 47 | const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 48 | import { fileURLToPath } from 'node:url'; |
| 49 | const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home |
| 50 | // The world's dev server -- see dev/world.sh. Kept inline rather than imported, |
| 51 | // so this stays standalone and does not load the harness. |
| 52 | const BASE = (process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`) + '/guide'; |
| 53 | const OUT = path.join(os.homedir(), '.cache/daimond/guide-shots'); |
| 54 | fs.mkdirSync(OUT, { recursive: true }); |
| 55 | |
| 56 | const PROVE = process.argv.includes('--prove'); |
| 57 | const LOCS = ['de', 'es', 'fr', 'ja', 'ko', 'pt-BR', 'zh-Hans']; |
| 58 | /// Every palette the guide can wear, as [tone, ink], mirroring frame.js. |
| 59 | const PALETTES = { |
| 60 | light: ['light', 'dark'], mist: ['light', 'dark'], linen: ['light', 'dark'], |
| 61 | lollypop: ['mid', 'dark'], sage: ['mid', 'dark'], dusk: ['mid', 'light'], |
| 62 | dark: ['dark', 'light'], amber: ['dark', 'light'], midnight: ['dark', 'light'], |
| 63 | forest: ['dark', 'light'], plum: ['dark', 'light'], |
| 64 | }; |
| 65 | |
| 66 | let bad = 0, ran = 0; |
| 67 | const say = (ok, what, detail) => { |
| 68 | ran++; |
| 69 | if (!ok) bad++; |
| 70 | console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${detail && !ok ? ' — ' + detail : ''}`); |
| 71 | }; |
| 72 | |
| 73 | // ── What the page has to say ───────────────────────────────────────── |
| 74 | // |
| 75 | // Matched against the RENDERED text, not the markup, so a fact hidden by a |
| 76 | // stylesheet does not count as said. |
| 77 | // |
| 78 | // A fact is a NAMED PROPERTY with a predicate, not a sentence. A check that |
| 79 | // matches one inflection reports a defect every time the copy is tightened, and |
| 80 | // reports nothing when the copy keeps the words and loses the meaning. |
| 81 | |
| 82 | /// Where a page first says that the passphrase does not follow you to a new |
| 83 | /// browser. An index, because the ordering check needs one. |
| 84 | const ACCOUNT_AT = /passphrase[^.]{0,220}\b(fresh|new|another|different)\b[^.]{0,30}browser|\b(fresh|new|another|different)\b[^.]{0,30}browser[^.]{0,220}passphrase/i; |
| 85 | |
| 86 | /// The account fact, in any wording: three things, all of which the code makes |
| 87 | /// true. The signing key is generated at random on the device that made the |
| 88 | /// account (`generatePair` in www/js/identity.js) and the passphrase only |
| 89 | /// derives the AES-GCM key that unwraps the stored copy, so (a) the same |
| 90 | /// passphrase in a fresh browser is a DIFFERENT account, (b) the key itself has |
| 91 | /// to be carried, and (c) these are the things that carry it. Say (a) without |
| 92 | /// (c) and the reader has a warning and no way out. |
| 93 | const ACCOUNT_FACT = (t) => |
| 94 | ACCOUNT_AT.test(t) |
| 95 | && /\b(different|separate|second|its own|new)\b[^.]{0,30}account/i.test(t) |
| 96 | && /Link another device/.test(t) |
| 97 | && /passkey|Export a backup/i.test(t); |
| 98 | |
| 99 | /// Sentences, near enough. The guide's prose is ordinary, and an abbreviation |
| 100 | /// inside one costs a split rather than a verdict. |
| 101 | const sentences = (t) => t.split(/(?<=[.!?;])\s+|\n+/); |
| 102 | |
| 103 | /// What a backup is, in any wording, and sentence by sentence rather than over |
| 104 | /// the whole page -- which is the difference between a check and a coincidence. |
| 105 | /// `doExport` writes DaimondIdentity.exportBundle(), so the key IS in the file, |
| 106 | /// and the private half of it is sealed under PBKDF2(passphrase, salt), so the |
| 107 | /// file is not a bearer token. Both halves, or the page loses an account or |
| 108 | /// hands one over. Page-wide matching cannot say this: "your key is not in it" |
| 109 | /// sits on a page that elsewhere says "encrypted" and "passphrase", and every |
| 110 | /// term would be found. |
| 111 | const BACKUP_FACT = (t) => { |
| 112 | const s = sentences(t); |
| 113 | // One sentence has to put the key IN the file. A sentence that says it is |
| 114 | // not there is the defect, so a negation next to the key disqualifies it. |
| 115 | const inIt = s.some((x) => /\b(backup|file you keep|exported file)\b/i.test(x) |
| 116 | && /\bkey\b/i.test(x) |
| 117 | && !/\bkey\b[^.]{0,40}\bnot\b|\bnot\b[^.]{0,40}\bkey\b/i.test(x)); |
| 118 | // And one has to say it travels wrapped, under the passphrase. |
| 119 | const wrapped = s.some((x) => /\bkey\b/i.test(x) |
| 120 | && /wrapped|sealed|encrypted/i.test(x) && /passphrase/i.test(x)); |
| 121 | return inIt && wrapped; |
| 122 | }; |
| 123 | |
| 124 | /// Decode the handful of entities a bank string can carry, and flatten |
| 125 | /// whitespace, so a bank value can be looked for in rendered text. |
| 126 | const flat = (s) => s |
| 127 | .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') |
| 128 | .replace(/"/g, '"').replace(/'/g, "'").replace(/ | /g, ' ') |
| 129 | .replace(/&#(\d+);/g, (m, n) => String.fromCharCode(Number(n))) |
| 130 | .replace(/\s+/g, ' ').trim(); |
| 131 | |
| 132 | /// The things a reader is stopped by. Each must also come before the steps. |
| 133 | const STOPPERS = [ |
| 134 | ['a snap or flatpak browser cannot do it', /snap or flatpak browser cannot/i], |
| 135 | ['and why: the hidden directory', /hidden director(y|ies)/i], |
| 136 | ['and what the browser reports instead', /Native host has exited/i], |
| 137 | ['apt install chromium-browser is the snap', /apt install chromium-browser/i], |
| 138 | ['Ubuntu has not shipped a deb since 20.04', /since 20\.04/], |
| 139 | ['what to install instead', /Chrome, Brave, Vivaldi or Edge/i], |
| 140 | ['the profile appears on first run', /profile directory .{0,40}created the first time|run that browser once/i], |
| 141 | ['the passphrase does not stand up a new browser', ACCOUNT_FACT], |
| 142 | ['so pair before you retire the old browser', /Link another device/], |
| 143 | ['and export a backup for the work', /Export a backup/], |
| 144 | ]; |
| 145 | |
| 146 | /// The rest of what is new. |
| 147 | const NEW_FACTS = [ |
| 148 | ['--check is the first thing to run', /install\.sh --check/], |
| 149 | ['run the hand by hand', /daimond-hand < \/dev\/null|daimond-hand < \/dev\/null/], |
| 150 | ['read the journal', /journal\/hand-\*\.jsonl/], |
| 151 | ['ask the extension', /DaimondHand\.status\(\)/], |
| 152 | ['the installer refuses a confined browser', /refuses rather than writing into it|stops rather than leaving/i], |
| 153 | ['--workspace does the folder step', /install\.sh --workspace/], |
| 154 | ]; |
| 155 | |
| 156 | /// Facts the page carried before the rewrite. None may have been dropped. |
| 157 | const KEPT = [ |
| 158 | ['not the same as the Machine workspace', /Not the same thing as the Machine workspace/], |
| 159 | ['it is free and not part of Pro', /it is not part of Pro/], |
| 160 | ['no tools are implemented', /Daimond implements no tools|implements no tools/], |
| 161 | ['the read-only system list', /\/libx32/], |
| 162 | ['there is no shell', /There is no shell, so there is no shell syntax/], |
| 163 | ['the manifest-path trap', /--manifest-path/], |
| 164 | ['mode 700 on the journal directory', /mode 700/], |
| 165 | ['the DAIMOND_HAND_ROOT trap', /DAIMOND_HAND_ROOT/], |
| 166 | ['the workspace.id token', /workspace\.id/], |
| 167 | ['unix sockets are always refused', /Named local sockets are refused/], |
| 168 | ['the Landlock ABI 9 reason', /ABI 9/], |
| 169 | ['ssh does not work, for two reasons', /ssh.{0,20}does not work|Two independent reasons/], |
| 170 | ['a toolchain needs a grant', /credentials\.toml/], |
| 171 | ['the network rule', /own output counts as content from outside/], |
| 172 | ['32-bit binaries are killed', /32-bit binaries are killed/], |
| 173 | ['the three rungs', /Ask every time/], |
| 174 | ['a rung never changes what is possible', /never changes what is possible/], |
| 175 | ['NixOS and Guix will not work', /nix\/store/], |
| 176 | ['Linux 5.13 to 6.6 is partial', /5\.13 to 6\.6/], |
| 177 | ['version managers other than two', /sdkman/], |
| 178 | ['BusyBox is fine', /BusyBox/], |
| 179 | ['the Terminal panel', /Terminal/], |
| 180 | ['the journal is hash-chained', /hash-chained/], |
| 181 | ['what is not protected: stat', /Asking about a file is not opening it/], |
| 182 | ['what is not protected: the deny-list', /The filter is a deny-list/], |
| 183 | ['what is not protected: timestamps', /Timestamps are not protected anywhere/], |
| 184 | ['taking it back, three ways', /uninstall\.sh/], |
| 185 | ]; |
| 186 | |
| 187 | const browser = await chromium.launch({ executablePath: CHROME }); |
| 188 | const ctx = await browser.newContext({ viewport: { width: 900, height: 1000 } }); |
| 189 | const page = await ctx.newPage(); |
| 190 | |
| 191 | /// The rendered page, with the prove-run's damage applied to the text. |
| 192 | let damage = null; |
| 193 | async function load(url) { |
| 194 | await page.goto(url, { waitUntil: 'networkidle' }); |
| 195 | // EVERY match, not the first. Removing one of two callouts leaves the other |
| 196 | // carrying half the sentences, and the check passes on damage -- which is how |
| 197 | // three of these proofs first reported success. |
| 198 | if (damage) await page.evaluate((d) => { |
| 199 | document.querySelectorAll(d.sel).forEach((el) => el.remove()); |
| 200 | }, damage); |
| 201 | return page.evaluate(() => document.body.innerText); |
| 202 | } |
| 203 | |
| 204 | const mo = await load(`${BASE}/machine-operations.html`); |
| 205 | |
| 206 | /// A required fact is a regular expression or a predicate over the page's text. |
| 207 | const said = (t, m) => (typeof m === 'function' ? m(t) : m.test(t)); |
| 208 | |
| 209 | for (const [what, m] of STOPPERS) say(said(mo, m), `machine-operations says: ${what}`); |
| 210 | for (const [what, m] of NEW_FACTS) say(said(mo, m), `machine-operations says: ${what}`); |
| 211 | for (const [what, m] of KEPT) say(said(mo, m), `still says: ${what}`); |
| 212 | |
| 213 | // ── Order ──────────────────────────────────────────────────────────── |
| 214 | // |
| 215 | // A warning below the instructions it should have prevented is decoration. |
| 216 | { |
| 217 | const stop = mo.search(/snap or flatpak browser cannot/i); |
| 218 | const acct = mo.search(ACCOUNT_AT); |
| 219 | const inst = mo.search(/^Installing it$/m); |
| 220 | const steps = mo.search(/cargo build --release/); |
| 221 | say(stop >= 0 && inst > stop, 'the snap warning comes before the installation steps', `${stop} vs ${inst}`); |
| 222 | say(acct >= 0 && inst > acct, 'the account warning comes before them too', `${acct} vs ${inst}`); |
| 223 | say(steps > inst, 'and the commands come after the heading that introduces them'); |
| 224 | // The very first thing after the lede, because it is the first thing that |
| 225 | // stops anyone. |
| 226 | say(stop < mo.length * 0.12, 'the snap warning is in the first eighth of the page', |
| 227 | `at ${((stop / mo.length) * 100).toFixed(1)}%`); |
| 228 | } |
| 229 | |
| 230 | // ── The callout, in every palette ──────────────────────────────────── |
| 231 | // |
| 232 | // `.note.stop` is a new colour, and a colour that works in two of eleven |
| 233 | // palettes is a colour that is wrong in nine. |
| 234 | { |
| 235 | const problems = []; |
| 236 | for (const [name, [tone, ink]] of Object.entries(PALETTES)) { |
| 237 | const found = await page.evaluate(({ name, tone, ink }) => { |
| 238 | const r = document.documentElement; |
| 239 | r.setAttribute('data-theme', name); |
| 240 | r.setAttribute('data-tone', tone); |
| 241 | r.setAttribute('data-ink', ink); |
| 242 | const rgb = (s) => { |
| 243 | const n = (s.match(/-?[\d.]+(?:e-?\d+)?/g) || []).map(Number); |
| 244 | const k = /^color\(/.test(s) ? 255 : 1; |
| 245 | return [n[0] * k, n[1] * k, n[2] * k, n.length > 3 ? n[3] : 1]; |
| 246 | }; |
| 247 | const bgOf = (el) => { |
| 248 | for (let n = el; n; n = n.parentElement) { |
| 249 | const c = rgb(getComputedStyle(n).backgroundColor); |
| 250 | if (c[3] > 0) return c.slice(0, 3); |
| 251 | } |
| 252 | return [255, 255, 255]; |
| 253 | }; |
| 254 | const lum = (c) => { |
| 255 | const f = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }); |
| 256 | return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; |
| 257 | }; |
| 258 | const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; |
| 259 | const out = []; |
| 260 | for (const box of document.querySelectorAll('.note.stop')) { |
| 261 | const bg = bgOf(box); |
| 262 | for (const el of [box, ...box.querySelectorAll('strong, a, li, code, em')]) { |
| 263 | const own = [...el.childNodes].filter((n) => n.nodeType === 3) |
| 264 | .map((n) => n.textContent).join('').trim(); |
| 265 | if (!own) continue; |
| 266 | const fg = rgb(getComputedStyle(el).color).slice(0, 3); |
| 267 | const r = ratio(fg, bgOf(el.parentElement || el)); |
| 268 | if (r < 4.5) out.push(`${r.toFixed(2)}:1 ${JSON.stringify(own.slice(0, 30))}`); |
| 269 | } |
| 270 | // The box's own edge, which is what makes it read as a warning |
| 271 | // rather than as a paragraph. |
| 272 | const edge = rgb(getComputedStyle(box).borderLeftColor).slice(0, 3); |
| 273 | if (ratio(edge, bg) < 3) out.push(`edge ${ratio(edge, bg).toFixed(2)}:1`); |
| 274 | } |
| 275 | return out; |
| 276 | }, { name, tone, ink }); |
| 277 | if (found.length) problems.push(`${name}: ${found.join('; ')}`); |
| 278 | } |
| 279 | say(problems.length === 0, 'the stop callouts clear 4.5:1 in all eleven palettes', problems.join(' | ')); |
| 280 | } |
| 281 | |
| 282 | // ── The correction ─────────────────────────────────────────────────── |
| 283 | // |
| 284 | // The translations are the half that rots quietly: a corrected English page |
| 285 | // keeps telling seven other languages the old thing, and nobody who reads |
| 286 | // English ever sees it. |
| 287 | // |
| 288 | // Their oracle is the BANK, not a list of foreign phrases. The locale pages are |
| 289 | // generated from dev/guide-i18n/<loc>.json by dev/guide_i18n.mjs, so "this |
| 290 | // locale carries the corrected sentence" means exactly "the locale page renders |
| 291 | // the bank's translation of the English run that carries it". Naming the |
| 292 | // phrases instead fails twice over: it called Japanese wrong for writing |
| 293 | // 作り直すのではなく where the list held 作り直しません, and it called an |
| 294 | // untranslated page RIGHT, because a fallback page carries the English source |
| 295 | // and the English source was one of the alternatives. |
| 296 | const BANK = path.join(ROOT, 'dev', 'guide-i18n'); |
| 297 | |
| 298 | /// Every locale's rendering of the English runs on `page` that match `carries`. |
| 299 | /// Returns what is stale, what is missing, and why. |
| 300 | async function translated(page, carries) { |
| 301 | const src = JSON.parse(fs.readFileSync(path.join(BANK, '_source.json'), 'utf8')); |
| 302 | const runs = (src[page] || []).filter((s) => carries.test(s)); |
| 303 | const stale = [], missing = []; |
| 304 | // The English page having no such sentence is itself the failure, and a |
| 305 | // louder one than any locale's: there is nothing left to translate. |
| 306 | if (!runs.length) missing.push(`the English ${page} has no run matching ${carries}`); |
| 307 | for (const loc of LOCS) { |
| 308 | const text = flat(await load(`${BASE}/${loc}/${page}`)); |
| 309 | if (/Anyone who imports it becomes you/i.test(text)) stale.push(loc); |
| 310 | const raw = JSON.parse(fs.readFileSync(path.join(BANK, `${loc}.json`), 'utf8')); |
| 311 | const bank = Object.assign({}, raw._common || {}, raw[page] || {}); |
| 312 | for (const run of runs) { |
| 313 | const t = bank[run]; |
| 314 | if (t === undefined) missing.push(`${loc}: no entry for "${run.slice(0, 48)}…"`); |
| 315 | else if (!text.includes(flat(t))) missing.push(`${loc}: the page does not render its entry`); |
| 316 | } |
| 317 | } |
| 318 | return { stale, missing }; |
| 319 | } |
| 320 | |
| 321 | { |
| 322 | const acc = await load(`${BASE}/accounts.html`); |
| 323 | // Not "a backup contains your identity", which is TRUE -- the export writes |
| 324 | // the wrapped bundle. The claim to keep out is that holding the file is |
| 325 | // enough, which is what the passphrase stops. |
| 326 | say(!/Anyone who imports it becomes you/i.test(acc), |
| 327 | 'accounts.html does not say that importing a backup makes anyone you'); |
| 328 | say(ACCOUNT_FACT(acc), |
| 329 | 'and says the passphrase alone starts a different account, and what carries the key'); |
| 330 | say(BACKUP_FACT(acc), |
| 331 | 'and says the key is in a backup, wrapped under the passphrase'); |
| 332 | |
| 333 | const sync = await load(`${BASE}/sync.html`); |
| 334 | say(/cannot travel on its own|nothing for it to open/.test(sync), |
| 335 | 'sync.html says the passphrase cannot bring the account over by itself'); |
| 336 | |
| 337 | const tr = await translated('accounts.html', /passphrase does not recreate/i); |
| 338 | say(tr.stale.length === 0, 'no translation still carries the old claim', tr.stale.join(', ')); |
| 339 | say(tr.missing.length === 0, 'every translation carries the corrected one', tr.missing.join(' | ')); |
| 340 | } |
| 341 | |
| 342 | // ── Shots ──────────────────────────────────────────────────────────── |
| 343 | if (!PROVE) { |
| 344 | for (const [name] of [['light'], ['dark'], ['linen'], ['amber']].map((x) => x)) { |
| 345 | const [tone, ink] = PALETTES[name]; |
| 346 | await page.goto(`${BASE}/machine-operations.html`, { waitUntil: 'networkidle' }); |
| 347 | await page.evaluate(({ name, tone, ink }) => { |
| 348 | const r = document.documentElement; |
| 349 | r.setAttribute('data-theme', name); |
| 350 | r.setAttribute('data-tone', tone); |
| 351 | r.setAttribute('data-ink', ink); |
| 352 | }, { name, tone, ink }); |
| 353 | // The top of the page, where the two stop boxes are. |
| 354 | await page.screenshot({ path: path.join(OUT, `facts-top-${name}.png`) }); |
| 355 | const box = await page.$$('.note.stop'); |
| 356 | if (box[1]) await box[1].screenshot({ path: path.join(OUT, `facts-account-${name}.png`) }); |
| 357 | } |
| 358 | // And the narrow width, where a callout with a list in it is likeliest to |
| 359 | // break out of its box. |
| 360 | await page.setViewportSize({ width: 360, height: 900 }); |
| 361 | await page.goto(`${BASE}/machine-operations.html`, { waitUntil: 'networkidle' }); |
| 362 | const narrow = await page.evaluate(() => { |
| 363 | const out = []; |
| 364 | if (document.documentElement.scrollWidth > document.documentElement.clientWidth + 1) out.push('page scrolls sideways'); |
| 365 | for (const b of document.querySelectorAll('.note.stop')) { |
| 366 | const r = b.getBoundingClientRect(); |
| 367 | if (r.right > document.documentElement.clientWidth + 1 || r.left < -1) out.push('callout out of the column'); |
| 368 | if (b.scrollWidth > b.clientWidth + 2) out.push('callout clips its own text'); |
| 369 | } |
| 370 | return out; |
| 371 | }); |
| 372 | say(narrow.length === 0, '360px wide: the stop callouts stay in the column', narrow.join('; ')); |
| 373 | const b2 = await page.$$('.note.stop'); |
| 374 | if (b2[1]) await b2[1].screenshot({ path: path.join(OUT, 'facts-account-narrow.png') }); |
| 375 | } |
| 376 | |
| 377 | // ── Proving the checks ─────────────────────────────────────────────── |
| 378 | // |
| 379 | // Every check above reads text off a rendered page, so one breakage shape -- |
| 380 | // removing the element that carries the sentence -- exercises all of them. Each |
| 381 | // group gets its own, aimed at the element that group is about. |
| 382 | if (PROVE) { |
| 383 | const CASES = [ |
| 384 | ['the stop callouts', '.note.stop', async () => { |
| 385 | const t = await load(`${BASE}/machine-operations.html`); |
| 386 | return !/snap or flatpak browser cannot/i.test(t) && !ACCOUNT_FACT(t) && t.search(ACCOUNT_AT) < 0; |
| 387 | }], |
| 388 | ['the callouts that carry the kept facts', '.note', async () => { |
| 389 | const t = await load(`${BASE}/machine-operations.html`); |
| 390 | return !KEPT.every(([, m]) => said(t, m)); |
| 391 | }], |
| 392 | ['the troubleshooting section', '#trouble', async () => { |
| 393 | // Removing only the heading leaves the commands, so the check that |
| 394 | // would notice is the ordering one; the heading is what anchors it. |
| 395 | const t = await load(`${BASE}/machine-operations.html`); |
| 396 | return !/When it does not work/.test(t); |
| 397 | }], |
| 398 | ['the accounts correction', 'main p', async () => { |
| 399 | const t = await load(`${BASE}/accounts.html`); |
| 400 | return !ACCOUNT_FACT(t); |
| 401 | }], |
| 402 | ['the translated sentence', 'main p', async () => { |
| 403 | // The same removal, on the seven generated pages: the bank still holds |
| 404 | // the entry, and the page no longer renders it. A check that only asked |
| 405 | // whether the bank had an entry would pass here. |
| 406 | const t = await translated('accounts.html', /passphrase does not recreate/i); |
| 407 | return t.missing.length === LOCS.length; |
| 408 | }], |
| 409 | ]; |
| 410 | for (const [what, sel, run] of CASES) { |
| 411 | damage = { sel }; |
| 412 | const caught = await run(); |
| 413 | say(caught, `broken on purpose: removing ${what} is caught`, 'the check still passed'); |
| 414 | damage = null; |
| 415 | } |
| 416 | // BACKUP_FACT, against the page as it actually read until this was corrected. |
| 417 | // Removing an element cannot prove this one -- three separate sentences carry |
| 418 | // the fact, and any of them satisfies the predicate -- and the failure was |
| 419 | // never a missing sentence. It was a page that said something, confidently, |
| 420 | // and said the opposite of what doExport does. So it is given those words |
| 421 | // back, with the neighbours that make a page-wide match pass: "encrypted" |
| 422 | // and "passphrase" both appear, a few rows up, about something else. |
| 423 | { |
| 424 | const was = [ |
| 425 | 'Change passphrase… — set a new one; your encrypted data is re-wrapped under it.', |
| 426 | 'Export a backup — write your chats, Diamonds and workspace files to a file you keep.', |
| 427 | 'It restores your work and not your account: your key is not in it.', |
| 428 | 'Import a backup… — bring an exported identity into this browser.', |
| 429 | 'Your account is a signing key held in this browser. The passphrase does not recreate it, only decrypts the copy already stored here, so the same passphrase in a fresh browser starts a separate account with its own credits and no Pro.', |
| 430 | 'Two things carry the key across and nothing else does: Link another device, which shows a pairing code to type into the new browser, and a passkey, which stands a new device up in one gesture.', |
| 431 | 'Take an Export a backup as well, for the chats, Diamonds and workspace files the key does not carry.', |
| 432 | 'A backup holds everything you have written.', |
| 433 | 'Not the key — importing it does not make anyone you — but every chat, Diamond and workspace file is in it as plain text, so keep it as private as the work itself.', |
| 434 | ].join('\n'); |
| 435 | say(!BACKUP_FACT(was), 'broken on purpose: "your key is not in it" is caught', 'the check still passed'); |
| 436 | // And the same text must still satisfy the OTHER property, or the case |
| 437 | // above would be proving nothing more than that some words changed. |
| 438 | say(ACCOUNT_FACT(was), 'and that page still says the account fact, so the two are independent'); |
| 439 | } |
| 440 | // The palette check, against a colour that does not clear the floor. |
| 441 | { |
| 442 | await page.goto(`${BASE}/machine-operations.html`, { waitUntil: 'networkidle' }); |
| 443 | const worst = await page.evaluate(() => { |
| 444 | const s = document.createElement('style'); |
| 445 | s.textContent = '.note.stop { color: color-mix(in srgb, currentColor 20%, var(--warn-bg)); }'; |
| 446 | document.head.appendChild(s); |
| 447 | const rgb = (x) => { const n = (x.match(/-?[\d.]+(?:e-?\d+)?/g) || []).map(Number); |
| 448 | const k = /^color\(/.test(x) ? 255 : 1; return [n[0] * k, n[1] * k, n[2] * k]; }; |
| 449 | const lum = (c) => { const f = c.map((v) => { const t = v / 255; return t <= 0.03928 ? t / 12.92 : Math.pow((t + 0.055) / 1.055, 2.4); }); |
| 450 | return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; }; |
| 451 | const box = document.querySelector('.note.stop'); |
| 452 | const li = box.querySelector('li') || box; |
| 453 | const [x, y] = [lum(rgb(getComputedStyle(li).color)), lum(rgb(getComputedStyle(box).backgroundColor))] |
| 454 | .sort((a, b) => b - a); |
| 455 | return (x + 0.05) / (y + 0.05); |
| 456 | }); |
| 457 | say(worst < 4.5, 'broken on purpose: a washed-out callout colour is caught', `${worst.toFixed(2)}:1`); |
| 458 | } |
| 459 | // The translation check, against a page that kept the old claim. |
| 460 | { |
| 461 | const stale = 'Anyone who imports it becomes you'; |
| 462 | const t = `nonsense ${stale} nonsense`; |
| 463 | say(/Anyone who imports it becomes you/i.test(t), |
| 464 | 'broken on purpose: the old claim in a translation is caught'); |
| 465 | } |
| 466 | } |
| 467 | |
| 468 | await browser.close(); |
| 469 | console.log(`\n${ran} checks, ${bad ? bad + ' FAILED' : 'all good'} — shots in ${OUT}`); |
| 470 | process.exit(bad ? 1 : 0); |