oxedyne/daimond/dev/verify_toolspanel.mjs
31.5 KiB, 1 run
created by r2519314175:753, 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_toolspanel.mjs — the Tools panel presents CAPABILITIES, and the shelf is real. |
| 2 | // |
| 3 | // The panel used to be a manifest: twenty-two rows, each the wire name of a function in |
| 4 | // the Rust registry. The unit is now the CAPABILITY — one thing Daimond does, said in |
| 5 | // those terms — and the functions live behind a disclosure on the capability that grants |
| 6 | // them. Six properties are worth a verifier and the rest is decoration: |
| 7 | // |
| 8 | // 1. THE ROW IS A CAPABILITY AND THE FUNCTION IS BEHIND IT. Asserted by MEANING and |
| 9 | // never by arity: the row named "Using a website" is the one that opens to reveal |
| 10 | // `web_click`, and `web_click` is NOT ON SCREEN until it is opened. Measured with |
| 11 | // `checkVisibility()`, so "hidden" is what the browser says rather than what the |
| 12 | // markup implies. |
| 13 | // |
| 14 | // 2. NOTHING THE REGISTRY OFFERS GOES MISSING. Every function `builtin_tools()` |
| 15 | // reports appears under exactly one capability — not one it has been told about, |
| 16 | // the whole list, so a tool added to Rust tomorrow is either placed or loud. The |
| 17 | // panel's `other` bucket catches the unplaced, and it must be EMPTY: a bucket that |
| 18 | // is allowed to fill is a bucket nobody reads. |
| 19 | // |
| 20 | // 3. A PACK THE GATEWAY DOES NOT SELL IS NOT DRAWN AS LOCKED. Typesetting carries a |
| 21 | // pack key in Rust, and a gateway answering an empty catalogue — an operator who has |
| 22 | // not switched the price on, or one who has taken it off again — leaves it included, |
| 23 | // and the panel says so. Nothing free may be drawn as buyable, and this is the check |
| 24 | // that holds that line. |
| 25 | // |
| 26 | // 4. A PACK THE GATEWAY DOES SELL MOVES TO THE SHELF, SAYS WHY IT IS LOCKED, AND STILL |
| 27 | // OPENS TO ITS FUNCTIONS. Same account, same build, one field of the gateway's |
| 28 | // answer different. Both directions are asked, because "it can lock" and "it does |
| 29 | // not lock what it should not" are different failures. |
| 30 | // |
| 31 | // 5. THE BUY BUTTON BUYS THE THING IT IS UNDER. Counted AT THE NETWORK — the request |
| 32 | // that leaves the page must carry that pack's key — so a button wired to the wrong |
| 33 | // row, or to nothing at all, is caught. A control drawn without a caller is this |
| 34 | // app's most expensive recurring defect. |
| 35 | // |
| 36 | // 6. THE LOCK REACHES THE ENGINE. `/api/tools` is the only thing that knows what was |
| 37 | // bought and the wasm is the only thing that can refuse a tool; the panel is the |
| 38 | // wire between them. Read back through `tool_locked('typst_compile')`, which is the |
| 39 | // very call `www/js/typst.js` makes before building the compiler — so this asserts |
| 40 | // the gate the person meets, not a variable the panel set. THIS WIRE DID NOT EXIST |
| 41 | // BEFORE THIS WORK: `set_locked_packs` had no production caller at all, and the |
| 42 | // whole typesetting gate was unreachable. |
| 43 | // |
| 44 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a deliberately |
| 45 | // damaged copy of a source file to the real page (through `page.route`, so the browser |
| 46 | // loads it as it loads any other script) and the run is expected to FAIL. A break that |
| 47 | // does not apply cleanly aborts rather than passing quietly: a check proved against code |
| 48 | // that was never broken is not proved at all. |
| 49 | // |
| 50 | // node dev/verify_toolspanel.mjs --break manifest # 1 fails: functions on show, no lid |
| 51 | // node dev/verify_toolspanel.mjs --break drop # 2 fails: a function reaches no card |
| 52 | // node dev/verify_toolspanel.mjs --break unplaced # 2 fails: the unplaced bucket fills |
| 53 | // node dev/verify_toolspanel.mjs --break presume # 3 fails: locked on the pack key alone |
| 54 | // node dev/verify_toolspanel.mjs --break nowhy # 4 fails: locked with no reason given |
| 55 | // node dev/verify_toolspanel.mjs --break misbuy # 5 fails: the button buys the wrong pack |
| 56 | // node dev/verify_toolspanel.mjs --break nopush # 6 fails: the engine is never told |
| 57 | // node dev/verify_toolspanel.mjs --break lid # 1 fails: the lid draws and does nothing |
| 58 | // node dev/verify_toolspanel.mjs # and then, clean |
| 59 | // |
| 60 | // eval "$(bash dev/world.sh 5 --up)" |
| 61 | // node dev/verify_toolspanel.mjs |
| 62 | // |
| 63 | // Needs dev/serve.mjs only. No gateway on :9002: every gateway route is stubbed here, and |
| 64 | // everything below the stub — the panel, the registry in the wasm, the lock the engine |
| 65 | // enforces — is the real code. |
| 66 | import fs from 'node:fs'; |
| 67 | import path from 'node:path'; |
| 68 | import { fileURLToPath } from 'node:url'; |
| 69 | import { open, shot, scratch, errors } from './harness.mjs'; |
| 70 | |
| 71 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 72 | const WWW = path.join(HERE, '..', 'www'); |
| 73 | |
| 74 | const BREAK = (() => { |
| 75 | const i = process.argv.indexOf('--break'); |
| 76 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 77 | })(); |
| 78 | |
| 79 | const PROFILE = scratch('pw', 'toolspanel' + (BREAK ? '-' + BREAK : '')); |
| 80 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 81 | |
| 82 | const ok = [], bad = []; |
| 83 | const check = (name, pass, detail) => { |
| 84 | (pass ? ok : bad).push(name); |
| 85 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 86 | }; |
| 87 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 88 | |
| 89 | // The tool `Tool::TypstCompile` names, and the pack it is sold in. The KEY is read back |
| 90 | // out of the build (below, once the registry answers) rather than written down here, |
| 91 | // because it is the pack's name and not the tool's -- `drop01` today, whatever the drop |
| 92 | // after it is keyed tomorrow -- and a literal here would go stale the day it changed and |
| 93 | // take checks 3-6 quietly with it. Nothing is proved by construction that way: the key is |
| 94 | // the INPUT to the experiment, and what is asserted is what the panel does with it. |
| 95 | // `dev/verify_typstpack.mjs` is where the key is checked against the gateway's catalogue. |
| 96 | // |
| 97 | // The NAME and the PRICE below are the stubbed gateway's own, and are deliberately NOT the |
| 98 | // shipped catalogue's: a check that reads back a figure this file also supplied would pass |
| 99 | // on any number, so the number is one the real catalogue does not carry, and the name is |
| 100 | // one no catalogue would ever ship. The display name is the operator's to change without a |
| 101 | // rebuild, so a stub borrowing whatever it says today would start agreeing with it by |
| 102 | // accident tomorrow -- which is the same trap the price avoids. |
| 103 | const FN = 'typst_compile'; |
| 104 | const PRICE = 2500; |
| 105 | const PACKNAME = 'Fixture Pack'; |
| 106 | let PACK = ''; |
| 107 | |
| 108 | // ── The breaks ─────────────────────────────────────────────────────── |
| 109 | // Each is a real edit to a real file, served in place of it. `find` must appear exactly |
| 110 | // once: a break that silently matched nothing would leave the suite green against |
| 111 | // working code and prove the opposite of what it claims. |
| 112 | const BREAKS = { |
| 113 | // The old panel, in one line: every function drawn as its own row, at the top |
| 114 | // level, with no capability over it and no lid on it. This is the shape the |
| 115 | // redesign replaced, and check 1 is what says it is gone. |
| 116 | manifest: [{ |
| 117 | file: 'js/tools.js', |
| 118 | find: '\t\tif (!row.fns.length) return card;', |
| 119 | with: '\t\tif (true) { row.fns.forEach(function (f) {\n' |
| 120 | + '\t\t\tvar l = el(\'div\', \'cap-fn\'); l.setAttribute(\'data-fn\', f.name);\n' |
| 121 | + '\t\t\tl.appendChild(el(\'code\', \'cap-fn-name\', f.name));\n' |
| 122 | + '\t\t\tcard.appendChild(l); }); return card; }', |
| 123 | }], |
| 124 | // A function the map does not place and the fallback does not catch: it is simply |
| 125 | // dropped on the floor. This is the silent-disappearance failure, and check 2 is |
| 126 | // what makes it loud. |
| 127 | drop: [{ |
| 128 | file: 'js/tools.js', |
| 129 | find: '\t\t\tvar id = capOf(fn.tool);\n', |
| 130 | with: '\t\t\tvar id = capOf(fn.tool);\n\t\t\tif (fn.tool === \'web_click\') return;\n', |
| 131 | }], |
| 132 | // A function the MAP does not place, which is a DIFFERENT failure from `drop` above and |
| 133 | // the one check 2 names in its second half. Nothing is lost here: `capOf` answers |
| 134 | // `other`, the panel draws the bucket, and the row reads "Not yet described" — so the |
| 135 | // first half of check 2 still passes and only the bucket assertion goes red. It is |
| 136 | // here because `drop` never filled the bucket, so the line that requires it to be |
| 137 | // EMPTY had never once been seen to fail: three tools shipped unplaced on 2026-08-17 |
| 138 | // and the gate caught them, with nothing to say the catching had ever been proved. |
| 139 | unplaced: [{ |
| 140 | file: 'js/tools.js', |
| 141 | find: '\'sheet_read\', \'doc_edit\', \'sheet_write\'', |
| 142 | with: '\'doc_edit\', \'sheet_write\'', |
| 143 | }], |
| 144 | // Locked on the pack key the build carries, without asking the gateway whether the |
| 145 | // pack is on sale. This is the exact way a free capability gets drawn as buyable: |
| 146 | // the browser presuming a sale the till knows nothing about. |
| 147 | presume: [{ |
| 148 | file: 'js/tools.js', |
| 149 | find: '\t\tif (!pack) return null;\n', |
| 150 | with: '\t\tif (!pack) return null;\n\t\treturn { tool: pack, name: pack, blurb: \'\',' |
| 151 | + ' price_minor: 999, unlocked: false, currency: \'usd\' };\n', |
| 152 | }], |
| 153 | // Locked, priced, and mute about it. The row shows a price and never says the |
| 154 | // account has not bought the pack, which tells the reader the cost and not the |
| 155 | // position they are in. |
| 156 | nowhy: [{ |
| 157 | file: 'js/tools.js', |
| 158 | find: '\t\tif (row.sale && !row.owned) {\n\t\t\ttxt.appendChild(el(\'div\', \'cap-why\',', |
| 159 | with: '\t\tif (false) {\n\t\t\ttxt.appendChild(el(\'div\', \'cap-why\',', |
| 160 | }], |
| 161 | // The button is drawn on the right row and buys something else. It looks perfect. |
| 162 | misbuy: [{ |
| 163 | file: 'js/tools.js', |
| 164 | find: '\t\t\tb.addEventListener(\'click\', function () { unlock(row.sale.tool); });', |
| 165 | with: '\t\t\tb.addEventListener(\'click\', function () { unlock(\'something_else\'); });', |
| 166 | }], |
| 167 | // The gateway is asked, the panel draws the answer, and the engine is never told — |
| 168 | // which is precisely the state this file found the app in. Every visible check |
| 169 | // still passes; only 6 goes red. |
| 170 | nopush: [{ |
| 171 | file: 'js/tools.js', |
| 172 | find: '\t\t\tvar mod = await import(PKG);\n\t\t\tmod.set_locked_packs(locked);', |
| 173 | with: '\t\t\tvar mod = await import(PKG);\n\t\t\tif (mod) { /* told nothing */ }', |
| 174 | }], |
| 175 | // The lid draws, the button counts, the label toggles — and nothing moves, because |
| 176 | // `display: flex` on the block outranks the browser's own `[hidden] { display: none }`. |
| 177 | // This is not hypothetical: the panel shipped its first draft that way, and a check |
| 178 | // that asked the CLASS or the attribute instead of the browser would have called it |
| 179 | // green. It is here so that check 1 is known to be measuring the reader's screen. |
| 180 | lid: [{ |
| 181 | file: 'css/tools.css', |
| 182 | find: '.cap-fns[hidden] { display: none; }', |
| 183 | with: '.cap-fns[hidden] { opacity: 0.99; }', |
| 184 | }], |
| 185 | }; |
| 186 | |
| 187 | /// What a browser should be told a served file is. A stylesheet delivered as JavaScript |
| 188 | /// is dropped without a word, and the break would then prove nothing. |
| 189 | const mime = (file) => /\.css$/.test(file) ? 'text/css' : 'application/javascript'; |
| 190 | |
| 191 | if (BREAK && !BREAKS[BREAK]) { |
| 192 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 193 | process.exit(2); |
| 194 | } |
| 195 | |
| 196 | /// `src` with `spec` applied, or a hard stop. Nothing is served that was not |
| 197 | /// verified to differ from what it was given. |
| 198 | function damaged(src, spec) { |
| 199 | const n = src.split(spec.find).length - 1; |
| 200 | if (n !== 1) { |
| 201 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 202 | + 'so nothing was broken and the run below would prove nothing.'); |
| 203 | process.exit(2); |
| 204 | } |
| 205 | return src.replace(spec.find, spec.with); |
| 206 | } |
| 207 | |
| 208 | /// The damaged files, ONE BODY PER FILE. |
| 209 | /// |
| 210 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 211 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 212 | /// work and does not say so: Playwright hands a request to the LAST route |
| 213 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 214 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 215 | function damagedFiles() { |
| 216 | const byFile = new Map(); |
| 217 | for (const spec of (BREAKS[BREAK] || [])) { |
| 218 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 219 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 220 | byFile.set(spec.file, damaged(src, spec)); |
| 221 | } |
| 222 | return byFile; |
| 223 | } |
| 224 | |
| 225 | // ── The stubbed gateway ────────────────────────────────────────────── |
| 226 | |
| 227 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 228 | const json = (body, status = 200) => ({ |
| 229 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 230 | }); |
| 231 | |
| 232 | // What `/api/tools` is answering right now. Swapped between phases, which is the whole |
| 233 | // experiment: one build, one account, one field of the gateway's answer different. |
| 234 | let catalogue = []; |
| 235 | |
| 236 | // Every pack checkout that LEFT the page, in order. A button wired to nothing shows as a |
| 237 | // list that did not grow; one wired to the wrong row shows as the wrong key in it. |
| 238 | const buys = []; |
| 239 | |
| 240 | async function stub(page) { |
| 241 | if (BREAK) { |
| 242 | for (const [file, body] of damagedFiles()) { |
| 243 | await page.route('**/' + file, r => r.fulfill({ |
| 244 | status: 200, contentType: mime(file), body, |
| 245 | })); |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | await page.route('**/api/account', r => r.fulfill(json({ ok: true }))); |
| 250 | await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-tp', challenge_id: 'cid-1' }))); |
| 251 | await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true }))); |
| 252 | await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] }))); |
| 253 | await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' }))); |
| 254 | |
| 255 | await page.route('**/api/tools', r => r.fulfill(json({ |
| 256 | ok: true, credits_minor: 5000, tools: catalogue, |
| 257 | }))); |
| 258 | |
| 259 | await page.route('**/api/checkout/pack', r => { |
| 260 | let b = {}; |
| 261 | try { b = JSON.parse(r.request().postData() || '{}'); } catch (e) { b = {}; } |
| 262 | buys.push(b.pack == null ? '' : String(b.pack)); |
| 263 | // No `url`, so the panel reports a failure and does NOT navigate away. What is |
| 264 | // under test is which key left the page, not Stripe. |
| 265 | return r.fulfill(json({ ok: false, error: 'stubbed' })); |
| 266 | }); |
| 267 | } |
| 268 | |
| 269 | // ── Reading the panel ──────────────────────────────────────────────── |
| 270 | |
| 271 | /// The panel as a reader meets it: one entry per capability card on screen, in order, |
| 272 | /// carrying only what is VISIBLE. |
| 273 | /// |
| 274 | /// `checkVisibility()` and not a class test: the question is whether the function is on |
| 275 | /// the reader's screen, and a `hidden` block, a `display:none` ancestor and a zero-height |
| 276 | /// container are all the same answer to that. Asked of the browser rather than inferred. |
| 277 | const readPanel = (page) => page.evaluate(() => { |
| 278 | const vis = (n) => !!(n && (n.checkVisibility ? n.checkVisibility() : n.offsetParent !== null)); |
| 279 | const secs = [...document.querySelectorAll('#tools-body > .tools-sec')].map(n => n.textContent.trim()); |
| 280 | return { |
| 281 | sections: secs, |
| 282 | none: [...document.querySelectorAll('#tools-body .tools-none')].map(n => n.textContent.trim()), |
| 283 | cards: [...document.querySelectorAll('#tools-body .cap')].map(c => ({ |
| 284 | id: c.getAttribute('data-cap'), |
| 285 | state: c.getAttribute('data-state'), |
| 286 | name: (c.querySelector('.cap-name') || {}).textContent || '', |
| 287 | blurb: (c.querySelector('.cap-blurb') || {}).textContent || '', |
| 288 | why: (c.querySelector('.cap-why') || {}).textContent || '', |
| 289 | chip: (c.querySelector('.cap-chip') || {}).textContent || '', |
| 290 | buy: (c.querySelector('[data-buy]') || {}).textContent || '', |
| 291 | buyKey: c.querySelector('[data-buy]') ? c.querySelector('[data-buy]').getAttribute('data-buy') : '', |
| 292 | more: (c.querySelector('.cap-more') || {}).textContent || '', |
| 293 | // Every function this card grants, and whether the reader can see it now. |
| 294 | fns: [...c.querySelectorAll('.cap-fn')].map(f => ({ |
| 295 | name: f.getAttribute('data-fn'), |
| 296 | shown: vis(f), |
| 297 | })), |
| 298 | })), |
| 299 | }; |
| 300 | }); |
| 301 | |
| 302 | /// Open the capability whose NAME is this, and hand back the panel afterwards. |
| 303 | /// |
| 304 | /// By name, never by index: the order is the panel's to choose and a test that indexed |
| 305 | /// would pass or fail on a reordering rather than on the code. |
| 306 | const openCap = async (page, name) => { |
| 307 | const did = await page.evaluate((want) => { |
| 308 | for (const c of document.querySelectorAll('#tools-body .cap')) { |
| 309 | const n = c.querySelector('.cap-name'); |
| 310 | if (n && n.textContent.trim() === want) { |
| 311 | const b = c.querySelector('.cap-more'); |
| 312 | if (!b) return 'no-lid'; |
| 313 | b.click(); |
| 314 | return 'ok'; |
| 315 | } |
| 316 | } |
| 317 | return 'no-card'; |
| 318 | }, name); |
| 319 | await page.waitForTimeout(150); |
| 320 | return did; |
| 321 | }; |
| 322 | |
| 323 | /// Which capability card holds a function, by the function's own name. |
| 324 | const cardWith = (panel, fn) => |
| 325 | panel.cards.filter(c => c.fns.some(f => f.name === fn)); |
| 326 | |
| 327 | /// Whether the reader can see a function right now, anywhere on the panel. |
| 328 | const fnShown = (panel, fn) => |
| 329 | panel.cards.some(c => c.fns.some(f => f.name === fn && f.shown)); |
| 330 | |
| 331 | /// The functions the Rust registry actually hands the agent, straight from the wasm. |
| 332 | /// The oracle for check 2: the panel is measured against the registry, not against a |
| 333 | /// list this file keeps. |
| 334 | const registry = (page) => page.evaluate(async () => { |
| 335 | const mod = await import('/pkg/oxedyne_daimond.js'); |
| 336 | return JSON.parse(mod.builtin_tools()).map(t => ({ tool: t.tool, pack: t.pack })); |
| 337 | }); |
| 338 | |
| 339 | /// What the ENGINE believes, asked the way `www/js/typst.js` asks it. |
| 340 | const engine = (page) => page.evaluate(async (fn) => { |
| 341 | const mod = await import('/pkg/oxedyne_daimond.js'); |
| 342 | return { locked: mod.locked_packs(), tool: mod.tool_locked(fn) }; |
| 343 | }, FN); |
| 344 | |
| 345 | // ── Driving ────────────────────────────────────────────────────────── |
| 346 | |
| 347 | const s = await open({ name: 'toolspanel', profile: PROFILE, signIn: false, connect: false }); |
| 348 | const { page } = s; |
| 349 | await stub(page); |
| 350 | |
| 351 | // The stub only takes effect on a load that comes after it, and sign-in reloads nothing — |
| 352 | // so the page is reopened with the routes in place. |
| 353 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 354 | const { signInAs } = await import('./harness.mjs'); |
| 355 | await signInAs(s, 'toolspanel'); |
| 356 | await page.waitForTimeout(2500); |
| 357 | |
| 358 | try { |
| 359 | // ── The instrument, before anything is measured with it ────── |
| 360 | // |
| 361 | // Everything below reads the panel through `readPanel` and the engine through |
| 362 | // `engine`. If either is lying — a selector that matches nothing, a visibility test |
| 363 | // that always answers the same — every assertion beneath is vacuous. So both are |
| 364 | // proved on a state whose answer is already known before they are trusted. |
| 365 | |
| 366 | await page.evaluate(() => { window.DaimondPanels.show('tools'); window.DaimondTools.reload(); }); |
| 367 | await page.waitForTimeout(1200); |
| 368 | |
| 369 | const reg = await registry(page); |
| 370 | check('the instrument can read the registry, and it is not empty', |
| 371 | Array.isArray(reg) && reg.length > 5 && reg.every(t => typeof t.tool === 'string'), |
| 372 | `${reg.length} function(s)`); |
| 373 | if (!reg.length) throw new Error('the wasm registry answered nothing; nothing below can be measured'); |
| 374 | |
| 375 | // The pack key this build sells the tool under, from the belt itself. It is the input to |
| 376 | // every phase below, so it is read once, here, and asserted to be a pack rather than a |
| 377 | // tool -- a build that named the tool would make the shelf checks meaningless. |
| 378 | PACK = (reg.find(t => t.tool === FN) || {}).pack || ''; |
| 379 | check('the registry says which PACK the sold tool belongs to, not which tool', |
| 380 | PACK.length > 0 && PACK !== FN, |
| 381 | PACK ? `"${FN}" is sold in "${PACK}"` : `"${FN}" carries no pack key`); |
| 382 | if (!PACK) throw new Error('the build sells nothing, so the shelf below cannot be measured'); |
| 383 | |
| 384 | let panel = await readPanel(page); |
| 385 | check('the instrument can see the panel, and it has capability cards', |
| 386 | panel.cards.length > 0 && panel.cards.every(c => c.name.length > 0), |
| 387 | `${panel.cards.length} card(s): ` + panel.cards.map(c => c.name).join(', ')); |
| 388 | if (!panel.cards.length) throw new Error('the panel drew no cards; nothing below can be measured'); |
| 389 | |
| 390 | // ── 1. The row is a capability; the function is behind it ──── |
| 391 | // |
| 392 | // Named rather than counted. `web_click` is a function nobody would put on a panel |
| 393 | // for a person, and "Using a website" is the capability it belongs to — so if the |
| 394 | // panel is still a manifest, the card named for the capability does not exist and |
| 395 | // `web_click` is on screen without anyone opening anything. |
| 396 | |
| 397 | const USE_WEB = panel.cards.find(c => c.fns.some(f => f.name === 'web_click')); |
| 398 | check('a capability, not a function, is what a row is named for', |
| 399 | !!USE_WEB && USE_WEB.name !== 'web_click' && !/^[a-z_]+$/.test(USE_WEB.name), |
| 400 | USE_WEB ? `the row granting web_click is called "${USE_WEB.name}"` : 'no row grants web_click'); |
| 401 | check('and it is described in what Daimond does, not in what it is called', |
| 402 | !!USE_WEB && USE_WEB.blurb.length > 30 && !USE_WEB.blurb.includes('web_click'), |
| 403 | USE_WEB ? USE_WEB.blurb.slice(0, 90) : ''); |
| 404 | |
| 405 | check('a function is NOT on screen until its capability is opened', |
| 406 | !fnShown(panel, 'web_click') && !fnShown(panel, 'file_glob'), |
| 407 | `web_click shown: ${fnShown(panel, 'web_click')}, file_glob shown: ${fnShown(panel, 'file_glob')}`); |
| 408 | |
| 409 | const opened = await openCap(page, USE_WEB ? USE_WEB.name : '(none)'); |
| 410 | panel = await readPanel(page); |
| 411 | check('opening that capability reveals the functions it grants', |
| 412 | opened === 'ok' && fnShown(panel, 'web_click') && fnShown(panel, 'web_type'), |
| 413 | `${opened}; web_click shown: ${fnShown(panel, 'web_click')}`); |
| 414 | // And only that one: opening a lid is not opening every lid, which is what makes |
| 415 | // the check above a disclosure rather than a redraw. |
| 416 | check('and only that one — the other capabilities stay shut', |
| 417 | !fnShown(panel, 'file_glob'), `file_glob shown: ${fnShown(panel, 'file_glob')}`); |
| 418 | |
| 419 | await openCap(page, USE_WEB ? USE_WEB.name : '(none)'); |
| 420 | panel = await readPanel(page); |
| 421 | check('and it shuts again', !fnShown(panel, 'web_click')); |
| 422 | |
| 423 | // ── 2. Nothing the registry offers goes missing ────────────── |
| 424 | // |
| 425 | // The whole registry, walked. Not a list of names this file keeps: the point is |
| 426 | // that a function added to Rust tomorrow, which nobody has told this map about, |
| 427 | // still reaches the panel. |
| 428 | |
| 429 | const placed = reg.filter(t => cardWith(panel, t.tool).length === 1); |
| 430 | const missing = reg.filter(t => cardWith(panel, t.tool).length === 0); |
| 431 | const twice = reg.filter(t => cardWith(panel, t.tool).length > 1); |
| 432 | check('every function the registry offers appears under exactly one capability', |
| 433 | placed.length === reg.length, |
| 434 | missing.length ? 'missing: ' + missing.map(t => t.tool).join(', ') |
| 435 | : twice.length ? 'twice: ' + twice.map(t => t.tool).join(', ') : `all ${reg.length}`); |
| 436 | |
| 437 | const orphan = panel.cards.find(c => c.id === 'other'); |
| 438 | check('and none of them fell into the unplaced bucket', |
| 439 | !orphan, orphan ? orphan.fns.map(f => f.name).join(', ') : 'the bucket is empty'); |
| 440 | |
| 441 | // ── 3. A pack nobody is selling is not drawn as locked ─────── |
| 442 | // |
| 443 | // The catalogue is empty in this phase — the state of any gateway whose operator has |
| 444 | // not priced the pack. `typst_compile` carries a pack key in Rust all the same, so |
| 445 | // this is where a browser that presumed from the key would put a price on something |
| 446 | // that is free. |
| 447 | |
| 448 | const typsetNow = cardWith(panel, FN)[0]; |
| 449 | check('typesetting is included while the gateway sells no pack for it', |
| 450 | !!typsetNow && typsetNow.state === 'included' && !typsetNow.buyKey, |
| 451 | typsetNow ? `state=${typsetNow.state} buy=${typsetNow.buyKey || 'none'}` : 'no card grants ' + FN); |
| 452 | check('so nothing on the panel is for sale, and the shelf says so plainly', |
| 453 | panel.cards.every(c => c.state !== 'locked') && panel.none.length === 1 |
| 454 | && panel.none[0].length > 10, |
| 455 | panel.none.join(' | ') || 'no empty-shelf line'); |
| 456 | // The shelf is a section whether or not anything is on it, so the first pack has |
| 457 | // somewhere to land without a change here. |
| 458 | check('and the shelf is a section on the panel even while it is empty', |
| 459 | panel.sections.length >= 2, panel.sections.join(' | ')); |
| 460 | |
| 461 | const eng0 = await engine(page); |
| 462 | check('and the engine holds nothing locked, so the tool runs', |
| 463 | eng0.tool === false && eng0.locked === '', JSON.stringify(eng0)); |
| 464 | |
| 465 | // ── 4-6. The same account, once the gateway is selling it ──── |
| 466 | // |
| 467 | // One field of the gateway's answer changes. Nothing else does: same build, same |
| 468 | // profile, same registry. |
| 469 | |
| 470 | catalogue = [{ |
| 471 | tool: PACK, name: PACKNAME, blurb: 'Typeset a document into a PDF.', |
| 472 | price_minor: PRICE, unlocked: false, currency: 'usd', |
| 473 | }]; |
| 474 | await page.evaluate(() => window.DaimondTools.reload()); |
| 475 | await page.waitForTimeout(900); |
| 476 | panel = await readPanel(page); |
| 477 | |
| 478 | const sold = cardWith(panel, FN)[0]; |
| 479 | check('a pack the gateway IS selling moves that capability onto the shelf, locked', |
| 480 | !!sold && sold.state === 'locked', |
| 481 | sold ? `state=${sold.state}` : 'no card grants ' + FN); |
| 482 | check('and it says WHY it is locked, naming the pack', |
| 483 | !!sold && sold.why.length > 20 && sold.why.includes(PACKNAME), |
| 484 | sold ? sold.why : ''); |
| 485 | check('and it is still named for the capability, not for the function', |
| 486 | !!sold && !sold.name.includes(FN), sold ? sold.name : ''); |
| 487 | check('and it still opens to the function it grants', |
| 488 | !!sold && sold.fns.some(f => f.name === FN), sold ? sold.fns.map(f => f.name).join(',') : ''); |
| 489 | check('and the price on the button is the catalogue\'s', |
| 490 | !!sold && /25\.00/.test(sold.buy), sold ? sold.buy : ''); |
| 491 | // The rest of the panel did not move: a change to one pack is a change to one row. |
| 492 | const stillFree = cardWith(panel, 'web_click')[0]; |
| 493 | check('and nothing else changed state — one pack, one row', |
| 494 | !!stillFree && stillFree.state === 'included', |
| 495 | stillFree ? stillFree.state : 'no card grants web_click'); |
| 496 | |
| 497 | // ── 6. The lock reaches the engine ─────────────────────────── |
| 498 | const eng1 = await engine(page); |
| 499 | check('the engine was told, so the tool the model calls is now refused', |
| 500 | eng1.tool === true && eng1.locked.split(',').indexOf(PACK) >= 0, |
| 501 | JSON.stringify(eng1)); |
| 502 | |
| 503 | // ── 5. The button buys the thing it is under ───────────────── |
| 504 | const before = buys.length; |
| 505 | await page.evaluate((fn) => { |
| 506 | for (const c of document.querySelectorAll('#tools-body .cap')) { |
| 507 | if ([...c.querySelectorAll('.cap-fn')].some(f => f.getAttribute('data-fn') === fn)) { |
| 508 | const b = c.querySelector('[data-buy]'); |
| 509 | if (b) b.click(); |
| 510 | return; |
| 511 | } |
| 512 | } |
| 513 | }, FN); |
| 514 | await page.waitForTimeout(900); |
| 515 | check('pressing Unlock asks the gateway to sell THAT pack', |
| 516 | buys.length === before + 1 && buys[buys.length - 1] === PACK, |
| 517 | `${buys.length - before} request(s): ${buys.slice(before).join(', ') || 'none'}`); |
| 518 | |
| 519 | // ── 4, the other direction ─────────────────────────────────── |
| 520 | // |
| 521 | // Bought. Without this the locked checks above prove only that the panel can draw a |
| 522 | // price — a panel that locked everything unconditionally would pass every one of them. |
| 523 | |
| 524 | catalogue = [{ |
| 525 | tool: PACK, name: PACKNAME, blurb: 'Typeset a document into a PDF.', |
| 526 | price_minor: PRICE, unlocked: true, currency: 'usd', |
| 527 | }]; |
| 528 | await page.evaluate(() => window.DaimondTools.reload()); |
| 529 | await page.waitForTimeout(900); |
| 530 | panel = await readPanel(page); |
| 531 | |
| 532 | const bought = cardWith(panel, FN)[0]; |
| 533 | check('an account that HAS bought the pack is not sold it again', |
| 534 | !!bought && bought.state === 'owned' && !bought.buyKey && bought.chip.length > 0, |
| 535 | bought ? `state=${bought.state} chip="${bought.chip}" buy=${bought.buyKey || 'none'}` : ''); |
| 536 | check('and it carries no reason to be locked, because it is not', |
| 537 | !!bought && bought.why === '', bought ? bought.why : ''); |
| 538 | |
| 539 | const eng2 = await engine(page); |
| 540 | check('and the engine lets the tool run again', |
| 541 | eng2.tool === false, JSON.stringify(eng2)); |
| 542 | |
| 543 | // ── The count the rail shows ───────────────────────────────── |
| 544 | // |
| 545 | // The rail row and this panel answer the same question, so they must count the same |
| 546 | // unit. A rail counting functions beside a panel listing capabilities is two answers. |
| 547 | const c1 = await page.evaluate(() => window.DaimondTools.counts()); |
| 548 | check('the count is in capabilities, matching what the panel drew', |
| 549 | c1.all === panel.cards.length && c1.have === panel.cards.filter( |
| 550 | x => x.state !== 'locked').length, |
| 551 | `${c1.have} of ${c1.all}, panel drew ${panel.cards.length} card(s)`); |
| 552 | |
| 553 | // A locked pack is something not yet had, so the two figures must part. |
| 554 | // |
| 555 | // The second entry is fictitious and its key must not be one the build knows, or the |
| 556 | // row would fold into the first and the count would not move. `inbox` was chosen to |
| 557 | // stay clear of the real pack; now that real keys are drop-numbered (`drop01`, and |
| 558 | // whatever follows) a themed key like this one cannot collide with a shipped pack at |
| 559 | // all, which is one more thing the drop-shaped key buys. |
| 560 | catalogue = [ |
| 561 | { tool: PACK, name: PACKNAME, blurb: 'b', price_minor: PRICE, unlocked: false, currency: 'usd' }, |
| 562 | { tool: 'inbox', name: 'Inbox', blurb: 'Daimond works your mail for you.', price_minor: 4500, unlocked: false, currency: 'usd' }, |
| 563 | ]; |
| 564 | await page.evaluate(() => window.DaimondTools.reload()); |
| 565 | await page.waitForTimeout(900); |
| 566 | panel = await readPanel(page); |
| 567 | const c2 = await page.evaluate(() => window.DaimondTools.counts()); |
| 568 | check('and two locked packs put the count two behind the total', |
| 569 | c2.all === c1.all + 1 && c2.have === c1.have - 1, |
| 570 | `was ${c1.have} of ${c1.all}, now ${c2.have} of ${c2.all}`); |
| 571 | |
| 572 | // A pack that names no function this build has — the shape a drop arrives in when its |
| 573 | // tools are not in this build yet — reaches the shelf on the catalogue alone, with no |
| 574 | // code here for it. |
| 575 | const later = panel.cards.find(c => c.name === 'Inbox'); |
| 576 | check('a pack naming no function this build has still reaches the shelf', |
| 577 | !!later && later.state === 'locked' && later.buyKey === 'inbox' |
| 578 | && later.fns.length === 0, |
| 579 | later ? `state=${later.state} buy=${later.buyKey} fns=${later.fns.length}` : 'no Inbox row'); |
| 580 | |
| 581 | // ── An unreachable gateway is not an empty account ─────────── |
| 582 | await page.unroute('**/api/tools'); |
| 583 | await page.route('**/api/tools', r => r.fulfill({ status: 500, contentType: 'application/json', |
| 584 | headers: CORS, body: '{"ok":false,"error":"down"}' })); |
| 585 | const engBefore = await engine(page); |
| 586 | await page.evaluate(() => window.DaimondTools.reload()); |
| 587 | await page.waitForTimeout(900); |
| 588 | panel = await readPanel(page); |
| 589 | check('a gateway that cannot be reached still shows what Daimond includes', |
| 590 | cardWith(panel, 'web_click').length === 1 && cardWith(panel, 'file_glob').length === 1, |
| 591 | `${panel.cards.length} card(s)`); |
| 592 | const engAfter = await engine(page); |
| 593 | check('and does not hand over a pack the account had not bought', |
| 594 | engAfter.locked === engBefore.locked, |
| 595 | `was "${engBefore.locked}", now "${engAfter.locked}"`); |
| 596 | |
| 597 | // A refusal is a decision, not a fault: a panel that logs an error every time the |
| 598 | // gateway is unreachable has taught its user to ignore the console. |
| 599 | const errs = errors(s).filter(e => |
| 600 | !/Failed to load resource/.test(e) && !/500/.test(e)); |
| 601 | check('nothing was drawn by way of an unhandled error', errs.length === 0, |
| 602 | errs.slice(0, 3).join(' | ')); |
| 603 | |
| 604 | await shot(s, 'toolspanel' + (BREAK ? '-' + BREAK : '')); |
| 605 | } finally { |
| 606 | await s.close(); |
| 607 | } |
| 608 | |
| 609 | console.log(`\npack checkouts seen: ${buys.length}${buys.length ? ' (' + buys.join(', ') + ')' : ''}`); |
| 610 | if (BREAK) { |
| 611 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 612 | + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 613 | process.exit(bad.length ? 0 : 1); // a break MUST fail something |
| 614 | } |
| 615 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 616 | process.exit(bad.length === 0 ? 0 : 1); |