oxedyne/daimond/dev/verify_guidemachine.mjs
6.0 KiB, 1 run
created by r2519314175:457, 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_guidemachine.mjs — the Machine Operations page, rendered. |
| 2 | // |
| 3 | // Three questions a static read of the markup cannot answer, and one that a |
| 4 | // grep can but should not have to: |
| 5 | // |
| 6 | // 1. Does the page scroll sideways at a phone width? A guide page full of |
| 7 | // shell commands is the one most likely to, because a <pre> has no soft |
| 8 | // wrap and a long command line is wider than a phone. |
| 9 | // 2. Does every code block keep its own overflow to itself? The rule is that |
| 10 | // a long line scrolls INSIDE its box; a box that hands the overflow to the |
| 11 | // document has not done its job. |
| 12 | // 3. Does it wear a real palette in both a light and a dark one? The page is |
| 13 | // served standalone here, so frame.js dresses it from the OS preference, |
| 14 | // and both are exercised. |
| 15 | // |
| 16 | // It also asserts the page is reachable from every other guide page and that |
| 17 | // every link out of it resolves, because a page nobody can navigate to is a |
| 18 | // page nobody reads. |
| 19 | // |
| 20 | // node dev/verify_guidemachine.mjs |
| 21 | // |
| 22 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777). No gateway, no model, no extension. |
| 23 | import os from 'node:os'; |
| 24 | import fs from 'node:fs'; |
| 25 | import path from 'node:path'; |
| 26 | import { pathToFileURL } from 'node:url'; |
| 27 | |
| 28 | const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 29 | const { chromium } = await import(pathToFileURL(PW).href); |
| 30 | const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 31 | const OUT = path.join(os.homedir(), '.cache/daimond/guide-shots'); |
| 32 | fs.mkdirSync(OUT, { recursive: true }); |
| 33 | |
| 34 | // The world's dev server -- see dev/world.sh. Kept inline rather than imported, |
| 35 | // so this stays standalone and does not load the harness. |
| 36 | const BASE = (process.env.DAIMOND_APP || `http://localhost:${process.env.DAIMOND_PORT || 8777}`) + '/guide'; |
| 37 | const PAGE = 'machine-operations.html'; |
| 38 | /// Every other English page, each of which must link here. |
| 39 | const SIBLINGS = ['index.html', 'interface.html', 'models.html', 'chats-and-diamonds.html', |
| 40 | 'email-web-files.html', 'accounts.html', 'sync.html', 'spending.html']; |
| 41 | /// The widths that matter: a phone, a narrow window, and a desktop column. |
| 42 | const WIDTHS = [360, 480, 900, 1280]; |
| 43 | |
| 44 | let bad = 0; |
| 45 | const check = (ok, what) => { console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) bad++; }; |
| 46 | |
| 47 | const browser = await chromium.launch({ executablePath: CHROME }); |
| 48 | |
| 49 | // ── The page itself, at every width, in both palettes ──────────────── |
| 50 | for (const scheme of ['light', 'dark']) { |
| 51 | const ctx = await browser.newContext({ colorScheme: scheme }); |
| 52 | const page = await ctx.newPage(); |
| 53 | for (const width of WIDTHS) { |
| 54 | await page.setViewportSize({ width, height: 900 }); |
| 55 | const r = await page.goto(`${BASE}/${PAGE}`, { waitUntil: 'networkidle' }); |
| 56 | check(!!r && r.ok(), `${scheme} ${width}px: HTTP ${r ? r.status() : '?'}`); |
| 57 | |
| 58 | const found = await page.evaluate(() => { |
| 59 | const out = { problems: [], theme: null, bg: '', fg: '', pres: 0 }; |
| 60 | const root = document.documentElement; |
| 61 | out.theme = root.getAttribute('data-theme'); |
| 62 | const cs = getComputedStyle(document.body); |
| 63 | out.bg = cs.backgroundColor; |
| 64 | out.fg = cs.color; |
| 65 | // The document must not scroll sideways, whatever is inside it. |
| 66 | if (root.scrollWidth > root.clientWidth + 1) { |
| 67 | out.problems.push(`page scrolls horizontally (${root.scrollWidth} > ${root.clientWidth})`); |
| 68 | } |
| 69 | // A code block may scroll inside itself and must not be wider than |
| 70 | // the column it sits in. |
| 71 | for (const pre of document.querySelectorAll('pre')) { |
| 72 | out.pres++; |
| 73 | const r = pre.getBoundingClientRect(); |
| 74 | if (r.right > root.clientWidth + 1 || r.left < -1) { |
| 75 | out.problems.push(`<pre> out of the column: ${r.left.toFixed(0)}..${r.right.toFixed(0)} vs 0..${root.clientWidth}`); |
| 76 | } |
| 77 | if (getComputedStyle(pre).overflowX !== 'auto' && getComputedStyle(pre).overflowX !== 'scroll') { |
| 78 | out.problems.push(`<pre> does not keep its overflow: overflow-x is ${getComputedStyle(pre).overflowX}`); |
| 79 | } |
| 80 | } |
| 81 | // Anything else whose text is clipped by its own box. |
| 82 | for (const el of document.querySelectorAll('.ui, kbd, code, th, td, .card, .note, figcaption')) { |
| 83 | if (el.closest('pre')) continue; |
| 84 | if (el.scrollWidth > el.clientWidth + 2 && getComputedStyle(el).overflow !== 'visible') { |
| 85 | out.problems.push(`clipped: <${el.tagName.toLowerCase()}> ${JSON.stringify(el.textContent.trim().slice(0, 40))}`); |
| 86 | } |
| 87 | } |
| 88 | return out; |
| 89 | }); |
| 90 | check(found.problems.length === 0, |
| 91 | `${scheme} ${width}px: nothing overflows${found.problems.length ? ' — ' + found.problems.join('; ') : ''}`); |
| 92 | if (width === WIDTHS[0]) { |
| 93 | check(!!found.theme && found.bg !== 'rgba(0, 0, 0, 0)', |
| 94 | `${scheme}: frame.js dressed it standalone (${found.theme}, bg ${found.bg})`); |
| 95 | check(found.pres >= 5, `${scheme}: the shell blocks are present (${found.pres})`); |
| 96 | } |
| 97 | await page.screenshot({ |
| 98 | path: path.join(OUT, `machine-operations-${scheme}-${width}.png`), |
| 99 | fullPage: width === 900, |
| 100 | }); |
| 101 | } |
| 102 | await ctx.close(); |
| 103 | } |
| 104 | |
| 105 | // ── Navigation, both ways ──────────────────────────────────────────── |
| 106 | const ctx = await browser.newContext(); |
| 107 | const page = await ctx.newPage(); |
| 108 | for (const sib of SIBLINGS) { |
| 109 | await page.goto(`${BASE}/${sib}`, { waitUntil: 'domcontentloaded' }); |
| 110 | const n = await page.evaluate((p) => |
| 111 | document.querySelectorAll(`a[href="${p}"]`).length, PAGE); |
| 112 | check(n >= 2, `${sib} links to it from the header and the footer (${n})`); |
| 113 | } |
| 114 | await page.goto(`${BASE}/${PAGE}`, { waitUntil: 'domcontentloaded' }); |
| 115 | const links = await page.evaluate(() => |
| 116 | [...document.querySelectorAll('main a[href], header a[href], footer a[href]')] |
| 117 | .map(a => a.getAttribute('href')) |
| 118 | .filter(h => h && !h.startsWith('#') && !h.startsWith('http'))); |
| 119 | for (const href of [...new Set(links)]) { |
| 120 | const r = await page.request.get(new URL(href, `${BASE}/${PAGE}`).href); |
| 121 | check(r.ok(), `link resolves: ${href} (${r.status()})`); |
| 122 | } |
| 123 | |
| 124 | await browser.close(); |
| 125 | console.log(`\n${bad ? `${bad} FAILED` : 'all good'} — shots in ${OUT}`); |
| 126 | process.exit(bad ? 1 : 0); |