Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_joinedup.mjs

34.6 KiB, 1 run

created by r2519314175:493, 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_joinedup.mjs — five things that were built and then never joined up,
2// and the one dead thing left behind by a sixth.
3//
4// Each is small. Together they are one failure repeated: a function, a page, a
5// field or a rule that exists, works, and is reached by nothing in production.
6// None of them showed up as a bug, because nothing was broken -- there was just
7// no way in.
8//
9// WHAT IS ASSERTED, AND WHY THAT RATHER THAN THE NEXT THING:
10//
11// 1. A USER CAN CHANGE THEIR PUBLIC HANDLE. `DaimondSync.claimHandle` had no
12// caller outside its own verifier, so an account minted `bright-finch-5m5m`
13// kept it for ever, and four refusal sentences translated into eight
14// languages could not be reached by anybody. So: the control is in the
15// Admin home, it is VISIBLE, pressing it reaches `claimHandle`, and each of
16// the four refusals arrives on screen as the sentence the catalogue holds
17// for it -- read out of `www/i18n/en.js`, not out of the page, because a
18// check that asks the page what it thinks the sentence is would pass with
19// every sentence replaced by the same one.
20//
21// 2. A WITHDRAWN FOLDER GRANT IS NOTICED ON THE PANEL'S OWN READS. Both
22// halves, because either alone is satisfiable by a mistake: a NotAllowed
23// failure under a folder root must raise `daimond:folder-lost`, and an
24// ORDINARY failure under the same root must raise nothing. A change that
25// fired on every failure would drop the user's folder because a file was
26// missing, which is its own bug and a worse one.
27//
28// And the predicate is compared, term by term, with `is_folder_lost` in
29// `src/wasm/opfs.rs`. It exists in two languages until the Rust side raises
30// the event itself; two copies of a rule drift, and this is what stops them
31// drifting quietly.
32//
33// 3. THE IMPROVE PANEL'S "i" OPENS THE PAGE ABOUT THE IMPROVE PANEL.
34// `guide/social.html` documents it and was reachable only from the guide's
35// own navigation; the button went to the tour of the whole frame.
36//
37// 4. A BACKUP'S VERSION IS READ. Both writers stamped `version: 1` and nothing
38// ever looked at it, so a format-2 file would have imported without a word
39// and silently dropped whatever format 2 added. Both halves again: a version
40// this build does not know is REFUSED, and one it does know is still taken.
41//
42// 5. THE REPRODUCIBLE-BUILD CHECK IS LINKED FROM THE APP. `www/verify.html` is
43// the one page that makes the transparency claim testable by the person
44// relying on it, and nothing in the PWA pointed at it. The link is asserted
45// to exist, to be visible, and to REACH a real page -- a link is not a
46// reach until something answers at the other end.
47//
48// 6. THE DEAD WORD-MARK SWAP IS GONE. Asserted at the STYLESHEET, not at the
49// source file: three rules selected `.empty-logo`, which nothing has
50// produced since the welcome copy went.
51//
52// EVERY CHECK IS PAIRED WITH ONE ASSERTING THE ELEMENT IS THERE. An element that
53// does not exist reports itself to a browser locator as *hidden*, and "hidden"
54// reads as "this is fine, it is just not showing" -- which is exactly how three
55// of these defects survived a suite this large.
56//
57// EACH IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a damaged
58// copy of a real file to the real page, and the run is expected to FAIL. A break
59// whose anchor does not appear exactly once aborts, because a check proved
60// against code that was never broken is not proved at all.
61//
62// node dev/verify_joinedup.mjs --break nohandle # the rename control is gone
63// node dev/verify_joinedup.mjs --break onesentence # one refusal for all four
64// node dev/verify_joinedup.mjs --break oldinfo # the "i" goes back to interface.html
65// node dev/verify_joinedup.mjs --break noverify # About drops the build check
66// node dev/verify_joinedup.mjs --break noversion # the import stops reading the version
67// node dev/verify_joinedup.mjs --break anyfailure # ANY failure drops the folder
68// node dev/verify_joinedup.mjs --break deadlogo # the dead CSS rule comes back
69// node dev/verify_joinedup.mjs # and then, clean
70//
71// bash dev/world.sh 15 --up ; eval "$(bash dev/world.sh 15 --env)"
72// node dev/verify_joinedup.mjs
73//
74// Needs dev/serve.mjs only. No gateway on :9002 -- the account endpoint is
75// stubbed here -- and no mock LLM: nothing here runs a turn.
76import fs from 'node:fs';
77import path from 'node:path';
78import { fileURLToPath } from 'node:url';
79import { open, scratch, errors } from './harness.mjs';
80
81const HERE = path.dirname(fileURLToPath(import.meta.url));
82const ROOT = path.join(HERE, '..');
83const WWW = path.join(ROOT, 'www');
84const SRC = 'js/daimond.js';
85
86const BREAK = (() => {
87 const i = process.argv.indexOf('--break');
88 return i > 0 ? String(process.argv[i + 1] || '') : '';
89})();
90
91const ok = [], bad = [];
92const check = (name, pass, detail) => {
93 (pass ? ok : bad).push(name);
94 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
95};
96
97// ── The breaks ───────────────────────────────────────────────────────
98const HANDLE_BTN =
99 '\t\t\tif (window.DaimondSync && DaimondSync.claimHandle) {\n'
100 + "\t\t\t\tvar hb = item(tOr('home.change_handle', 'Change public handle…'), doChangeHandle);\n"
101 + "\t\t\t\thb.id = 'admin-change-handle';\n"
102 + '\t\t\t}\n';
103const REFUSAL = "\t\t\tawait noticeDialog(title, (r && r.message) || t('handle.failed'));";
104const IMP_INFO = "\t\tif (window.DaimondWeb && DaimondWeb.guide) DaimondWeb.guide('social.html');\n"
105 + "\t\telse window.open('guide/social.html', '_blank');";
106const VERIFY_IN = '\t\tbody.appendChild(check);\n';
107const VER_GUARD = "\t\t\tvar ver = data.version === undefined ? BACKUP_VERSION : data.version;\n"
108 + "\t\t\tif (typeof ver !== 'number' || !isFinite(ver) || ver > BACKUP_VERSION) {";
109const LOST_TEST = "\tvar text = (e && typeof e === 'object' && e.message) ? String(e.message) : String(e);\n"
110 + "\treturn text.indexOf('NotAllowed') >= 0;";
111const LOGO_RULE = '.empty-state h2 { margin: 0; color: var(--text-primary); font-size: var(--fs-3xl); }';
112
113const BREAKS = {
114 // The control is not drawn. `claimHandle` goes back to having no caller.
115 nohandle: [{ file: SRC, find: HANDLE_BTN, with: '' }],
116 // One sentence for all four noes: the user is told "try again shortly" when
117 // the real answer was "somebody else has that name".
118 onesentence: [{ file: SRC, find: REFUSAL,
119 with: "\t\t\tawait noticeDialog(title, t('handle.failed'));" }],
120 // The "i" goes back to the page about the whole frame.
121 oldinfo: [{ file: SRC, find: IMP_INFO,
122 with: "\t\tif (window.DaimondWeb && DaimondWeb.guide) DaimondWeb.guide('interface.html');\n"
123 + "\t\telse window.open('guide/interface.html', '_blank');" }],
124 // About draws the link and never puts it in the card, which is the shape of
125 // every defect in this file: it exists, and nothing reaches it.
126 noverify: [{ file: SRC, find: VERIFY_IN, with: '' }],
127 // The import stops reading the version, exactly as it shipped.
128 noversion: [{ file: SRC, find: VER_GUARD, with: '\t\t\tif (false) {' }],
129 // Any failure at all counts as a withdrawn grant, so a missing file costs the
130 // user their folder.
131 anyfailure: [{ file: SRC, find: LOST_TEST, with: '\treturn true;' }],
132 // The dead rule comes back, in the stylesheet where it lived.
133 deadlogo: [{ file: 'css/app.css', find: LOGO_RULE,
134 with: '.empty-state .empty-logo { width: 56px; height: 56px; opacity: 0.9; margin-bottom: 4px; }\n'
135 + LOGO_RULE }],
136};
137
138if (BREAK && !BREAKS[BREAK]) {
139 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
140 process.exit(2);
141}
142
143/// The damaged source of ONE file, with every edit for it applied, or a hard
144/// stop. All of a file's edits go into one body: registering two routes for the
145/// same URL serves only the last, so a break with two edits in one file would
146/// silently deliver half of itself.
147function damaged(file, specs) {
148 let src = fs.readFileSync(path.join(WWW, file), 'utf8');
149 for (const spec of specs) {
150 const n = src.split(spec.find).length - 1;
151 if (n !== 1) {
152 console.error(`break '${BREAK}': an anchor appears ${n} times in ${file}, `
153 + 'so it was not applied and the run below would prove nothing.');
154 process.exit(2);
155 }
156 src = src.replace(spec.find, spec.with);
157 }
158 return src;
159}
160
161const MIME = { js: 'application/javascript', css: 'text/css' };
162
163/// Serve every file this break damages, before anything navigates.
164async function serveBroken(page) {
165 if (!BREAK) return;
166 const byFile = new Map();
167 for (const spec of BREAKS[BREAK]) {
168 if (!byFile.has(spec.file)) byFile.set(spec.file, []);
169 byFile.get(spec.file).push(spec);
170 }
171 for (const [file, specs] of byFile) {
172 const body = damaged(file, specs);
173 const type = MIME[file.split('.').pop()] || 'text/plain';
174 await page.route('**/' + file, r => r.fulfill({ status: 200, contentType: type, body }));
175 }
176}
177
178// ── The English the user is owed ─────────────────────────────────────
179// Read from the CATALOGUE FILE, not from the page.
180const CATALOGUE = (() => {
181 const src = fs.readFileSync(path.join(WWW, 'i18n/en.js'), 'utf8');
182 const out = {};
183 for (const key of ['handle.taken', 'handle.invalid', 'handle.reserved', 'handle.failed']) {
184 const m = src.match(new RegExp(`'${key.replace('.', '\\.')}':\\s*'((?:[^'\\\\]|\\\\.)*)'`));
185 if (!m) {
186 console.error(`i18n/en.js carries no '${key}' -- the check below would compare nothing.`);
187 process.exit(2);
188 }
189 out[key] = m[1].replace(/\\'/g, "'").replace(/\\\\/g, '\\');
190 }
191 return out;
192})();
193
194// ── The stubbed gateway ──────────────────────────────────────────────
195// A real namespace owner in miniature: one name to one account, a 409 for a name
196// somebody else holds, and a switch that makes it fall over, which is the fourth
197// refusal and the only one no name can provoke.
198const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
199const json = (body, status = 200) => ({
200 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
201});
202
203const ME = 'acct-joinedup';
204const SOMEONE = 'acct-somebody-else';
205const THEIRS = 'quiet-heron-22aa';
206const MINTED = 'bright-finch-5m5m';
207const RESERVED = new Set(['admin', 'daimond', 'support', 'system', 'root', 'operator']);
208
209const NS = new Map([[THEIRS, SOMEONE]]);
210let clock = 1_700_000_000;
211let mine = '';
212let mineTs = 0;
213let sick = false; // the gateway falls over, for `handle.failed`
214
215function normalise(raw) {
216 const h = String(raw || '').trim().toLowerCase();
217 if (h.length < 3 || h.length > 24) return null;
218 if (!/^[a-z0-9-]+$/.test(h)) return null;
219 if (h.startsWith('-') || h.endsWith('-') || h.includes('--')) return null;
220 return h;
221}
222
223function accountRoute(r) {
224 const req = r.request();
225 const url = new URL(req.url());
226 const method = req.method();
227
228 if (process.env.JU_DEBUG) console.log(` [stub] ${method} ${url.pathname}${url.search} body=${req.postData() || ''}`);
229 if (method === 'POST' && url.searchParams.get('op') === 'handle') {
230 if (sick) return r.fulfill(json({ ok: false }, 500));
231 let body = {};
232 try { body = JSON.parse(req.postData() || '{}'); } catch (e) { body = {}; }
233 const want = normalise(body.handle);
234 if (!want) return r.fulfill(json({ ok: false, reason: 'invalid' }, 400));
235 if (RESERVED.has(want)) return r.fulfill(json({ ok: false, reason: 'reserved' }, 400));
236 const holder = NS.get(want);
237 if (holder && holder !== ME) return r.fulfill(json({ ok: false, reason: 'taken' }, 409));
238 NS.delete(mine);
239 mine = want;
240 mineTs = ++clock;
241 NS.set(mine, ME);
242 return r.fulfill(json({ ok: true, reason: 'claimed', handle: mine, handle_ts: mineTs }));
243 }
244 if (method === 'GET') {
245 return r.fulfill(json({ ok: true, account_id: ME, handle: mine, handle_ts: mineTs }));
246 }
247 // Registration. The gateway mints the name; the client never proposes one.
248 if (!mine) { mine = MINTED; mineTs = ++clock; NS.set(mine, ME); }
249 return r.fulfill(json({ ok: true, account_id: ME, created: true, handle: mine, handle_ts: mineTs }));
250}
251
252/// Everything the page needs to boot signed in against a gateway that answers.
253async function stubGateway(page) {
254 // A catch-all FIRST, because Playwright gives a request to the route
255 // registered LAST -- so everything below overrides this, and everything the
256 // app asks for that is not below gets a bland yes instead of a 502.
257 //
258 // It is here for one reason. This world has no gateway, and opening the Admin
259 // drawer sets off the console-role probe, the devices list and the balance
260 // poll; each 502 knocked the session over, the app re-bootstrapped, and
261 // `handleReady()` was false for a moment every few hundred milliseconds. Half
262 // the renames in this file were then refused with "try again shortly" -- in
263 // the one check whose whole subject is WHICH refusal was given.
264 await page.route('**/api/**', r => r.fulfill(json({ ok: true })));
265 await page.route(/\/api\/account(\?|$)/, accountRoute);
266 await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-j', challenge_id: 'cid-j' })));
267 await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true })));
268 await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] })));
269 await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' })));
270 await page.route('**/api/sync**', r => r.fulfill(json({ ok: true, version: 0, blob: null })));
271}
272
273// ── 2. The predicate, in two languages ───────────────────────────────
274//
275// STATIC, and first, because it needs no browser and because it is the check
276// that stops the duplication rotting. The three terms are pulled OUT of the Rust
277// source: nothing here restates them, so a change to `is_folder_lost` that the
278// browser mirror does not follow goes red.
279{
280 const rs = fs.readFileSync(path.join(ROOT, 'src/wasm/opfs.rs'), 'utf8');
281 const js = fs.readFileSync(path.join(WWW, SRC), 'utf8');
282
283 const pred = (rs.match(/pub fn is_folder_lost\(result: &str\) -> bool \{([\s\S]*?)\n\}/) || [])[1] || '';
284 check('src/wasm/opfs.rs still has an is_folder_lost to mirror', !!pred.trim(),
285 pred.trim().slice(0, 80));
286
287 // `workspace_mode() == "folder" && result.contains("NotAllowed")` — taken to
288 // pieces so the JS can be asked about each piece by name.
289 const mode = (pred.match(/(\w+)\(\)\s*==\s*"([^"]+)"/) || []);
290 const text = (pred.match(/contains\("([^"]+)"\)/) || []);
291 check('and its two terms read out of the Rust, not restated here',
292 !!mode[1] && !!mode[2] && !!text[1], `${mode[1]}()=="${mode[2]}" && contains("${text[1]}")`);
293
294 const mirror = (js.match(/function folderWasLost\(e\) \{([\s\S]*?)\n\}/) || [])[1] || '';
295 // One line, so a failure prints something a reader can take in.
296 const flat = mirror.replace(/\s+/g, ' ').trim().slice(0, 150);
297 check('js/daimond.js carries the mirror', !!mirror.trim());
298 check('which asks the same question of the root', mirror.includes(`${mode[1]}() !== '${mode[2]}'`)
299 || mirror.includes(`${mode[1]}() === '${mode[2]}'`), flat);
300 check('and tests the same word in the failure', mirror.includes(`'${text[1]}'`), flat);
301
302 // The event name, likewise read from the Rust constant rather than typed.
303 const ev = (rs.match(/pub const FOLDER_LOST_EVENT: &str = "([^"]+)"/) || [])[1] || '';
304 check('the browser raises the event the Rust names', !!ev && js.includes(`dispatchEvent(new CustomEvent('${ev}'))`), ev);
305 check('and the one handler is still the only listener for it',
306 js.split(`window.addEventListener('${ev}', handlePermissionLoss)`).length === 2,
307 'handlePermissionLoss');
308
309 // Every direct-call site the panel owns now reports. Named individually,
310 // because "at least one" is satisfied by the first one anybody wired.
311 const sites = js.split('noteFolderLost(').length - 1;
312 check('and every direct read reports through it, not just one',
313 sites >= 6, `${sites} references (declaration plus call sites)`);
314}
315
316// ── 6. The dead word-mark swap, at the source ────────────────────────
317{
318 const js = fs.readFileSync(path.join(WWW, SRC), 'utf8');
319 check('nothing in js/daimond.js queries .empty-logo any more',
320 !/querySelector\([^)]*empty-logo/.test(js));
321}
322
323// ── The one browser session that carries items 1, 3, 4 and 5 ─────────
324const PROFILE = scratch('pw', 'joinedup' + (BREAK ? '-' + BREAK : ''));
325fs.rmSync(PROFILE, { recursive: true, force: true });
326
327const s = await open({
328 name: 'joinedup', profile: PROFILE, connect: false,
329 route: async (page) => { await serveBroken(page); await stubGateway(page); },
330});
331const p = s.page;
332if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`);
333
334/// The dialog standing at the front, or null.
335const front = () => p.evaluate(() => {
336 const ds = document.querySelectorAll('.modal.dlg');
337 if (!ds.length) return null;
338 const d = ds[ds.length - 1];
339 return {
340 title: ((d.querySelector('h2') || {}).textContent || '').trim(),
341 msg: ((d.querySelector('.dlg-msg') || {}).textContent || '').trim(),
342 input: !!d.querySelector('.dlg-input'),
343 };
344});
345
346/// Dismiss whatever dialog is at the front, and wait for the card to LEAVE.
347///
348/// Not a fixed pause: a dialog fades out, and one still in the document while the
349/// next is opened means `front()` reads the card that is going rather than the
350/// one that came -- which is a flake that reports the wrong sentence, in a check
351/// whose whole subject is which sentence was said.
352const dismiss = async () => {
353 await p.evaluate(() => {
354 const ds = document.querySelectorAll('.modal.dlg');
355 if (!ds.length) return;
356 const b = ds[ds.length - 1].querySelector('.dlg-ok');
357 if (b) b.click();
358 });
359 await settled();
360};
361
362/// Wait until no dialog is standing.
363const settled = () => p.waitForFunction(
364 () => document.querySelectorAll('.modal.dlg').length === 0, null, { timeout: 8000 });
365
366try {
367
368// ── 1. A user can change their public handle ─────────────────────────
369
370await p.waitForFunction(() => !!(window.DaimondSync && window.DaimondSync.handle()),
371 null, { timeout: 15000 }).catch(() => { /* asserted below, with the value */ });
372const minted = await p.evaluate(() => window.DaimondSync.handle());
373check('the account was minted a public name it did not choose', minted === MINTED,
374 `showing '${minted}'`);
375
376// The session has to be up before a rename is asked for, or `claimHandle`
377// answers "not just now" for a reason that has nothing to do with the name --
378// which is a flake in the one check that is about WHICH refusal was given.
379await p.waitForFunction(() => {
380 try { return !!(window.DaimondGateway && DaimondGateway.state().authed); }
381 catch (e) { return false; }
382}, null, { timeout: 15000 }).catch(() => { /* the refusals below will say so */ });
383
384await p.click('#user-row');
385await p.waitForTimeout(500);
386
387// PRESENT, then VISIBLE, and in that order. A locator asked whether a control
388// that does not exist is visible answers "no", which reads as "it is there and
389// hidden" -- and that answer is how three of these defects survived.
390const btn = p.locator('#admin-change-handle');
391check('the Admin home HAS a control for the public handle', await btn.count() === 1,
392 `${await btn.count()} matching element(s)`);
393check('and it is on screen, not merely in the document',
394 await btn.count() === 1 && await btn.first().isVisible());
395const row = p.locator('#account-handle');
396check('and the name it changes is shown beside the fingerprint', await row.count() === 1);
397check('with the minted name in it',
398 await row.count() === 1 && (await row.first().innerText()).includes(minted),
399 await row.count() ? (await row.first().innerText()).trim() : '(no row)');
400
401/// Press the control, type `name`, press Save, and report what came back.
402async function claim(name) {
403 await settled().catch(() => {}); // nothing left over from the last one
404 // A control that is not there is a FAILURE of the checks above, not a crash
405 // here: the run has to reach its tally or a break proves nothing but a stack.
406 const pressed = await p.evaluate(() => {
407 const b = document.getElementById('admin-change-handle');
408 if (!b) return false;
409 b.click();
410 return true;
411 });
412 if (!pressed) {
413 return { prefilled: '', said: null, gw: null,
414 showing: await p.evaluate(() => window.DaimondSync.handle()) };
415 }
416 await p.waitForSelector('.modal.dlg .dlg-input', { timeout: 8000 });
417 const prefilled = await p.inputValue('.modal.dlg .dlg-input');
418 await p.fill('.modal.dlg .dlg-input', name);
419 await p.evaluate(() => {
420 const ds = document.querySelectorAll('.modal.dlg');
421 ds[ds.length - 1].querySelector('.dlg-ok').click();
422 });
423 // The prompt closes the moment Save is pressed; the refusal arrives a network
424 // round trip LATER. So this waits for a dialog that is not the prompt -- one
425 // with no input in it -- and reads "none within four seconds" as the rename
426 // having been accepted, because a rename that takes says nothing at all.
427 //
428 // Waiting for "no dialog with an input" instead was the flake: it was true
429 // the instant the prompt closed, so the refusal was read as null perhaps one
430 // run in six.
431 await p.waitForFunction(() => {
432 const ds = document.querySelectorAll('.modal.dlg');
433 return ds.length > 0 && !ds[ds.length - 1].querySelector('.dlg-input');
434 }, null, { timeout: 4000 }).catch(() => { /* accepted: there is no notice */ });
435 const said = await front();
436 if (said) await dismiss();
437 else await settled().catch(() => {});
438 const gw = await p.evaluate(() => {
439 try {
440 return { authed: !!DaimondGateway.state().authed,
441 safe: !!(window.DaimondSafe && DaimondSafe.on()) };
442 } catch (e) { return { authed: null, safe: null }; }
443 });
444 return { prefilled, said, gw, showing: await p.evaluate(() => window.DaimondSync.handle()) };
445}
446
447const taken = await claim(THEIRS);
448check('the dialog opens pre-filled with the name the account holds now',
449 taken.prefilled === minted, `pre-filled '${taken.prefilled}'`);
450const invalid = await claim('no');
451const reserved = await claim('admin');
452// The half without which the whole property could be met by refusing everything.
453const won = await claim('copper-marten-8p8p');
454// LAST, and on purpose: a 500 from the account endpoint is the one answer that
455// can leave the session unsure of itself, and a check that has already had its
456// answers is a check that cannot be spoiled by it.
457sick = true;
458const failed = await claim('another-name-entirely');
459sick = false;
460
461const heard = {
462 taken: taken.said && taken.said.msg,
463 invalid: invalid.said && invalid.said.msg,
464 reserved: reserved.said && reserved.said.msg,
465 failed: failed.said && failed.said.msg,
466};
467check('a name somebody else holds is refused in the words written for it',
468 heard.taken === CATALOGUE['handle.taken'],
469 String(heard.taken) + ' [gw ' + JSON.stringify(taken.gw) + ']');
470check('a name that is not a name, likewise',
471 heard.invalid === CATALOGUE['handle.invalid'], String(heard.invalid));
472check('a name the operator keeps, likewise',
473 heard.reserved === CATALOGUE['handle.reserved'], String(heard.reserved));
474check('and a gateway that cannot answer says so as itself',
475 heard.failed === CATALOGUE['handle.failed'], String(heard.failed));
476check('which is four different sentences, not one repeated',
477 new Set(Object.values(heard)).size === 4,
478 Object.entries(heard).map(([k, v]) => k + '=' + String(v).slice(0, 24)).join(' | '));
479check('and no refusal moved the name',
480 taken.showing === minted && invalid.showing === minted
481 && reserved.showing === minted && failed.showing === won.showing,
482 [taken.showing, invalid.showing, reserved.showing, failed.showing].join(', '));
483
484check('a rename that the gateway takes, takes here too',
485 won.showing === 'copper-marten-8p8p', `showing '${won.showing}'`);
486const rowAfter = await p.locator('#account-handle').innerText().catch(() => '');
487check('and the panel redraws to say the new name',
488 rowAfter.includes('copper-marten-8p8p'), rowAfter.trim());
489
490await p.keyboard.press('Escape');
491await p.waitForTimeout(300);
492
493// ── 5. The reproducible-build check is linked from About ─────────────
494
495await p.evaluate(() => document.getElementById('about-btn').click());
496await p.waitForSelector('.about-body', { timeout: 8000 });
497const link = p.locator('.about-body a.about-verify');
498check('About HAS a way through to the build check', await link.count() === 1,
499 `${await link.count()} matching element(s)`);
500check('and it is on screen, not merely in the document',
501 await link.count() === 1 && await link.first().isVisible());
502const href = await link.count() ? await link.first().getAttribute('href') : '';
503const rel = await link.count() ? (await link.first().getAttribute('rel') || '') : '';
504const tgt = await link.count() ? (await link.first().getAttribute('target') || '') : '';
505check('pointing at the app\'s own verify page', /(^|\/)verify\.html$/.test(String(href)), String(href));
506check('in a tab of its own, with no handle back on this window',
507 tgt === '_blank' && rel.includes('noopener') && rel.includes('noreferrer'),
508 `target='${tgt}' rel='${rel}'`);
509check('and it says what it is for, not just where it goes',
510 /\bbuild\b/i.test(await link.first().innerText().catch(() => '')),
511 (await link.first().innerText().catch(() => '')).trim());
512// A link is not a reach until something answers at the other end.
513const served = await p.evaluate(async (u) => {
514 try {
515 const r = await fetch(u, { cache: 'no-store' });
516 return { status: r.status, body: (await r.text()).slice(0, 4000) };
517 } catch (e) { return { status: 0, body: String(e) }; }
518}, href || 'verify.html');
519check('and the page at the other end is really the build check',
520 served.status === 200 && served.body.includes('id="verdict"'),
521 `HTTP ${served.status}`);
522await p.keyboard.press('Escape');
523await p.waitForTimeout(300);
524
525// ── 3. The Improve panel's "i" opens the page about the panel ────────
526
527await p.evaluate(() => window.DaimondPanels.show('social'));
528await p.waitForTimeout(500);
529const info = p.locator('#social-info');
530check('the Improve panel HAS its circled i', await info.count() === 1,
531 `${await info.count()} matching element(s)`);
532check('and it is on screen, not merely in the document',
533 await info.count() === 1 && await info.first().isVisible());
534await p.evaluate(() => document.getElementById('social-info').click());
535await p.waitForTimeout(800);
536const framed = await p.evaluate(() => {
537 const f = document.getElementById('web-frame');
538 return f ? (f.getAttribute('src') || '') : '(no frame)';
539});
540check('and pressing it opens the guide page about THIS panel',
541 /guide\/social\.html$/.test(framed), framed);
542const guidePage = await p.evaluate(async () => {
543 try {
544 const r = await fetch('guide/social.html', { cache: 'no-store' });
545 return { status: r.status, len: (await r.text()).length };
546 } catch (e) { return { status: 0, len: 0 }; }
547});
548check('and that page exists and has something on it',
549 guidePage.status === 200 && guidePage.len > 2000, JSON.stringify(guidePage));
550
551// ── 6. The dead rule is gone from the stylesheets the page loaded ────
552//
553// Asked of the LOADED CSS and not of the file on disk: what matters is that no
554// rule in the running app selects a class nothing produces.
555const deadRules = await p.evaluate(() => {
556 const out = [];
557 for (const sheet of document.styleSheets) {
558 let rules;
559 try { rules = sheet.cssRules; } catch (e) { continue; } // a sheet we may not read
560 for (const r of rules) {
561 if (r.selectorText && r.selectorText.includes('empty-logo')) out.push(r.selectorText);
562 }
563 }
564 return out;
565});
566check('no stylesheet rule selects .empty-logo, which nothing draws',
567 deadRules.length === 0, deadRules.join(' | '));
568const drawn = await p.evaluate(() => document.querySelectorAll('.empty-logo').length);
569check('and nothing on the page carries the class', drawn === 0, String(drawn));
570
571// ── 4. A backup's version is read ────────────────────────────────────
572
573const NEWER = scratch('joinedup-newer.json');
574const KNOWN = scratch('joinedup-known.json');
575fs.writeFileSync(NEWER, JSON.stringify({
576 format: 'daimond-backup', version: 99, exported: new Date().toISOString(),
577 chats: [], workspace: [], diamonds: [],
578}));
579fs.writeFileSync(KNOWN, JSON.stringify({
580 format: 'daimond-backup', version: 1, exported: new Date().toISOString(),
581 chats: [], workspace: [], diamonds: [],
582}));
583
584/// Feed a file to the Import control and report the dialog that answers.
585async function importFile(file) {
586 // Never throws. Under `--break noversion` the first import is ACCEPTED, and
587 // acknowledging it reloads the app into the lock screen -- so the second
588 // import below has nothing to press. That is the defect behaving exactly as
589 // it shipped, and it must show up as a red check rather than as a stack trace
590 // that stops the rest of the file from running.
591 try {
592 await p.click('#user-row', { timeout: 8000 });
593 await p.waitForTimeout(400);
594 const chooser = p.waitForEvent('filechooser', { timeout: 15000 });
595 await p.click('button.admin-item:has-text("Import a backup")', { timeout: 8000 });
596 await (await chooser).setFiles(file);
597 await p.waitForSelector('.modal.dlg .dlg-ok', { timeout: 15000 }).catch(() => {});
598 return front();
599 } catch (e) { return null; }
600}
601
602const refused = await importFile(NEWER);
603check('a backup this build does not know is refused, out loud',
604 !!refused && /newer/i.test(refused.title), refused ? refused.title : '(no dialog)');
605check('and the refusal says BOTH numbers, so the reader knows what to run',
606 !!refused && refused.msg.includes('99') && refused.msg.includes('1'),
607 refused ? refused.msg.slice(0, 120) : '');
608check('and nothing was restored: the app did not reload out from under it',
609 await p.evaluate(() => !!(window.DaimondSync && window.DaimondSync.handle())).catch(() => false));
610// Dismissed only if it really was the refusal. A "Backup restored" notice --
611// which is what a build that does not read the version puts here -- reloads the
612// app when it is acknowledged, and the run would then be pressing buttons on a
613// lock screen.
614if (refused && /newer/i.test(refused.title)) {
615 await dismiss().catch(() => {});
616 await p.waitForTimeout(400);
617}
618
619// The other half. Without it, "refuse everything" would pass the check above.
620const accepted = await importFile(KNOWN);
621check('a backup this build DOES know is still restored',
622 !!accepted && !/newer/i.test(accepted.title), accepted ? accepted.title : '(no dialog)');
623
624// Nothing threw on the way past. Failed loads are excluded: this world has no
625// gateway on :9002, so every endpoint not stubbed here answers 502, which says
626// nothing about any of the above. An exception does.
627const threw = errors(s).filter(e => !/Failed to load resource|502|WebSocket|ERR_/i.test(e));
628check('nothing threw in the page', threw.length === 0, threw.slice(0, 3).join(' | '));
629
630} finally {
631 await s.close();
632}
633
634// ── 2, live. A withdrawn grant, on the panel's own read ──────────────
635//
636// The wasm glue is patched so that the workspace root reports itself as a real
637// folder and a read fails the way a revoked grant fails. That is fault injection
638// and not a break: it stands in for a browser event this harness cannot produce,
639// and BOTH outcomes are asked for -- a NotAllowed failure must raise the alarm,
640// and an ordinary one under the same root must not.
641const GLUE = 'pkg/oxedyne_daimond.js';
642const NOT_ALLOWED = "OPFS: open dir 'x' failed: NotAllowedError: The request is not allowed "
643 + 'by the user agent or the platform in the current context.. [IO, File, Read]';
644
645const glueSrc = fs.readFileSync(path.join(WWW, GLUE), 'utf8');
646const MODE_FN = 'export function workspace_mode() {';
647const READ_FN = 'export function read_file(path) {';
648for (const anchor of [MODE_FN, READ_FN]) {
649 if (glueSrc.split(anchor).length !== 2) {
650 console.error(`the wasm glue no longer carries '${anchor}' exactly once; `
651 + 'the injection below would prove nothing.');
652 process.exit(2);
653 }
654}
655const glue = glueSrc
656 .replace(MODE_FN, MODE_FN + "\n return 'folder';\t// injected: a real folder is open")
657 .replace(READ_FN, READ_FN
658 + `\n return Promise.reject(String(path).indexOf('lost/') === 0`
659 + `\n ? ${JSON.stringify(NOT_ALLOWED)}`
660 + `\n : "OPFS: open file 'x' failed: NotFoundError. [IO, File, Read]");`);
661
662const PROFILE2 = scratch('pw', 'joinedup-lost' + (BREAK ? '-' + BREAK : ''));
663fs.rmSync(PROFILE2, { recursive: true, force: true });
664const s2 = await open({
665 name: 'joinedlost', profile: PROFILE2, connect: false,
666 route: async (page) => {
667 await serveBroken(page);
668 await page.route('**/' + GLUE, r => r.fulfill({
669 status: 200, contentType: 'application/javascript', body: glue,
670 }));
671 // The counter has to exist before the app does, or a boot-time read would
672 // be missed and the count below would be the wrong number for a good reason.
673 await page.addInitScript(() => {
674 window.__lost = 0;
675 window.addEventListener('daimond:folder-lost', () => { window.__lost++; });
676 });
677 },
678});
679try {
680 const p2 = s2.page;
681 await p2.waitForFunction(() => !!(window.DaimondCore && window.DaimondCore.readFile),
682 null, { timeout: 15000 });
683 check('the workspace root reports itself as a real folder',
684 await p2.evaluate(async () => (await import('/pkg/oxedyne_daimond.js')).workspace_mode()) === 'folder');
685
686 const before = await p2.evaluate(() => window.__lost);
687 // An ORDINARY failure first. A read that fails because the file is not there
688 // must NOT cost the user their folder -- and asking this FIRST means the
689 // positive half below cannot be satisfied by an alarm already ringing.
690 await p2.evaluate(() => window.DaimondCore.readFile('ordinary/missing.md').catch(() => {}));
691 await p2.waitForTimeout(300);
692 const afterOrdinary = await p2.evaluate(() => window.__lost);
693 check('a read that fails because the file is missing raises no alarm',
694 afterOrdinary === before, `${before} → ${afterOrdinary}`);
695
696 await p2.evaluate(() => window.DaimondCore.readFile('lost/anything.md').catch(() => {}));
697 await p2.waitForTimeout(300);
698 const afterLost = await p2.evaluate(() => window.__lost);
699 check('and a read that fails on a withdrawn grant raises it, from the PANEL\'s own door',
700 afterLost > afterOrdinary, `${afterOrdinary} → ${afterLost}`);
701
702 const threw2 = errors(s2).filter(e => !/Failed to load resource|502|WebSocket|ERR_|NotFound|NotAllowed/i.test(e));
703 check('nothing threw in the injected page', threw2.length === 0, threw2.slice(0, 3).join(' | '));
704} finally {
705 await s2.close();
706}
707
708console.log(`\n${ok.length} passed, ${bad.length} failed`);
709if (bad.length) console.log('failed: ' + bad.join(', '));
710process.exit(bad.length ? 1 : 0);