Oregami
Repositories/oxedyne/daimond

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.
66import fs from 'node:fs';
67import path from 'node:path';
68import { fileURLToPath } from 'node:url';
69import { open, shot, scratch, errors } from './harness.mjs';
70
71const HERE = path.dirname(fileURLToPath(import.meta.url));
72const WWW = path.join(HERE, '..', 'www');
73
74const BREAK = (() => {
75 const i = process.argv.indexOf('--break');
76 return i > 0 ? String(process.argv[i + 1] || '') : '';
77})();
78
79const PROFILE = scratch('pw', 'toolspanel' + (BREAK ? '-' + BREAK : ''));
80fs.rmSync(PROFILE, { recursive: true, force: true });
81
82const ok = [], bad = [];
83const check = (name, pass, detail) => {
84 (pass ? ok : bad).push(name);
85 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
86};
87const 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.
103const FN = 'typst_compile';
104const PRICE = 2500;
105const PACKNAME = 'Fixture Pack';
106let 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.
112const 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.
189const mime = (file) => /\.css$/.test(file) ? 'text/css' : 'application/javascript';
190
191if (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.
198function 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.
215function 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
227const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
228const 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.
234let 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.
238const buys = [];
239
240async 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.
277const 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.
306const 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.
324const 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.
328const 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.
334const 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.
340const 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
347const s = await open({ name: 'toolspanel', profile: PROFILE, signIn: false, connect: false });
348const { page } = s;
349await 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.
353await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
354const { signInAs } = await import('./harness.mjs');
355await signInAs(s, 'toolspanel');
356await page.waitForTimeout(2500);
357
358try {
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
609console.log(`\npack checkouts seen: ${buys.length}${buys.length ? ' (' + buys.join(', ') + ')' : ''}`);
610if (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}
615console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
616process.exit(bad.length === 0 ? 0 : 1);