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. |
| 76 | import fs from 'node:fs'; |
| 77 | import path from 'node:path'; |
| 78 | import { fileURLToPath } from 'node:url'; |
| 79 | import { open, scratch, errors } from './harness.mjs'; |
| 80 | |
| 81 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 82 | const ROOT = path.join(HERE, '..'); |
| 83 | const WWW = path.join(ROOT, 'www'); |
| 84 | const SRC = 'js/daimond.js'; |
| 85 | |
| 86 | const BREAK = (() => { |
| 87 | const i = process.argv.indexOf('--break'); |
| 88 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 89 | })(); |
| 90 | |
| 91 | const ok = [], bad = []; |
| 92 | const check = (name, pass, detail) => { |
| 93 | (pass ? ok : bad).push(name); |
| 94 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 95 | }; |
| 96 | |
| 97 | // ── The breaks ─────────────────────────────────────────────────────── |
| 98 | const 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'; |
| 103 | const REFUSAL = "\t\t\tawait noticeDialog(title, (r && r.message) || t('handle.failed'));"; |
| 104 | const IMP_INFO = "\t\tif (window.DaimondWeb && DaimondWeb.guide) DaimondWeb.guide('social.html');\n" |
| 105 | + "\t\telse window.open('guide/social.html', '_blank');"; |
| 106 | const VERIFY_IN = '\t\tbody.appendChild(check);\n'; |
| 107 | const 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) {"; |
| 109 | const LOST_TEST = "\tvar text = (e && typeof e === 'object' && e.message) ? String(e.message) : String(e);\n" |
| 110 | + "\treturn text.indexOf('NotAllowed') >= 0;"; |
| 111 | const LOGO_RULE = '.empty-state h2 { margin: 0; color: var(--text-primary); font-size: var(--fs-3xl); }'; |
| 112 | |
| 113 | const 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 | |
| 138 | if (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. |
| 147 | function 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 | |
| 161 | const MIME = { js: 'application/javascript', css: 'text/css' }; |
| 162 | |
| 163 | /// Serve every file this break damages, before anything navigates. |
| 164 | async 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. |
| 180 | const 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. |
| 198 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 199 | const json = (body, status = 200) => ({ |
| 200 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 201 | }); |
| 202 | |
| 203 | const ME = 'acct-joinedup'; |
| 204 | const SOMEONE = 'acct-somebody-else'; |
| 205 | const THEIRS = 'quiet-heron-22aa'; |
| 206 | const MINTED = 'bright-finch-5m5m'; |
| 207 | const RESERVED = new Set(['admin', 'daimond', 'support', 'system', 'root', 'operator']); |
| 208 | |
| 209 | const NS = new Map([[THEIRS, SOMEONE]]); |
| 210 | let clock = 1_700_000_000; |
| 211 | let mine = ''; |
| 212 | let mineTs = 0; |
| 213 | let sick = false; // the gateway falls over, for `handle.failed` |
| 214 | |
| 215 | function 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 | |
| 223 | function 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. |
| 253 | async 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 ───────── |
| 324 | const PROFILE = scratch('pw', 'joinedup' + (BREAK ? '-' + BREAK : '')); |
| 325 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 326 | |
| 327 | const s = await open({ |
| 328 | name: 'joinedup', profile: PROFILE, connect: false, |
| 329 | route: async (page) => { await serveBroken(page); await stubGateway(page); }, |
| 330 | }); |
| 331 | const p = s.page; |
| 332 | if (BREAK) console.log(`\n*** RUNNING UNDER --break ${BREAK}: failures below are the point ***\n`); |
| 333 | |
| 334 | /// The dialog standing at the front, or null. |
| 335 | const 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. |
| 352 | const 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. |
| 363 | const settled = () => p.waitForFunction( |
| 364 | () => document.querySelectorAll('.modal.dlg').length === 0, null, { timeout: 8000 }); |
| 365 | |
| 366 | try { |
| 367 | |
| 368 | // ── 1. A user can change their public handle ───────────────────────── |
| 369 | |
| 370 | await p.waitForFunction(() => !!(window.DaimondSync && window.DaimondSync.handle()), |
| 371 | null, { timeout: 15000 }).catch(() => { /* asserted below, with the value */ }); |
| 372 | const minted = await p.evaluate(() => window.DaimondSync.handle()); |
| 373 | check('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. |
| 379 | await 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 | |
| 384 | await p.click('#user-row'); |
| 385 | await 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. |
| 390 | const btn = p.locator('#admin-change-handle'); |
| 391 | check('the Admin home HAS a control for the public handle', await btn.count() === 1, |
| 392 | `${await btn.count()} matching element(s)`); |
| 393 | check('and it is on screen, not merely in the document', |
| 394 | await btn.count() === 1 && await btn.first().isVisible()); |
| 395 | const row = p.locator('#account-handle'); |
| 396 | check('and the name it changes is shown beside the fingerprint', await row.count() === 1); |
| 397 | check('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. |
| 402 | async 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 | |
| 447 | const taken = await claim(THEIRS); |
| 448 | check('the dialog opens pre-filled with the name the account holds now', |
| 449 | taken.prefilled === minted, `pre-filled '${taken.prefilled}'`); |
| 450 | const invalid = await claim('no'); |
| 451 | const reserved = await claim('admin'); |
| 452 | // The half without which the whole property could be met by refusing everything. |
| 453 | const 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. |
| 457 | sick = true; |
| 458 | const failed = await claim('another-name-entirely'); |
| 459 | sick = false; |
| 460 | |
| 461 | const 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 | }; |
| 467 | check('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) + ']'); |
| 470 | check('a name that is not a name, likewise', |
| 471 | heard.invalid === CATALOGUE['handle.invalid'], String(heard.invalid)); |
| 472 | check('a name the operator keeps, likewise', |
| 473 | heard.reserved === CATALOGUE['handle.reserved'], String(heard.reserved)); |
| 474 | check('and a gateway that cannot answer says so as itself', |
| 475 | heard.failed === CATALOGUE['handle.failed'], String(heard.failed)); |
| 476 | check('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(' | ')); |
| 479 | check('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 | |
| 484 | check('a rename that the gateway takes, takes here too', |
| 485 | won.showing === 'copper-marten-8p8p', `showing '${won.showing}'`); |
| 486 | const rowAfter = await p.locator('#account-handle').innerText().catch(() => ''); |
| 487 | check('and the panel redraws to say the new name', |
| 488 | rowAfter.includes('copper-marten-8p8p'), rowAfter.trim()); |
| 489 | |
| 490 | await p.keyboard.press('Escape'); |
| 491 | await p.waitForTimeout(300); |
| 492 | |
| 493 | // ── 5. The reproducible-build check is linked from About ───────────── |
| 494 | |
| 495 | await p.evaluate(() => document.getElementById('about-btn').click()); |
| 496 | await p.waitForSelector('.about-body', { timeout: 8000 }); |
| 497 | const link = p.locator('.about-body a.about-verify'); |
| 498 | check('About HAS a way through to the build check', await link.count() === 1, |
| 499 | `${await link.count()} matching element(s)`); |
| 500 | check('and it is on screen, not merely in the document', |
| 501 | await link.count() === 1 && await link.first().isVisible()); |
| 502 | const href = await link.count() ? await link.first().getAttribute('href') : ''; |
| 503 | const rel = await link.count() ? (await link.first().getAttribute('rel') || '') : ''; |
| 504 | const tgt = await link.count() ? (await link.first().getAttribute('target') || '') : ''; |
| 505 | check('pointing at the app\'s own verify page', /(^|\/)verify\.html$/.test(String(href)), String(href)); |
| 506 | check('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}'`); |
| 509 | check('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. |
| 513 | const 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'); |
| 519 | check('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}`); |
| 522 | await p.keyboard.press('Escape'); |
| 523 | await p.waitForTimeout(300); |
| 524 | |
| 525 | // ── 3. The Improve panel's "i" opens the page about the panel ──────── |
| 526 | |
| 527 | await p.evaluate(() => window.DaimondPanels.show('social')); |
| 528 | await p.waitForTimeout(500); |
| 529 | const info = p.locator('#social-info'); |
| 530 | check('the Improve panel HAS its circled i', await info.count() === 1, |
| 531 | `${await info.count()} matching element(s)`); |
| 532 | check('and it is on screen, not merely in the document', |
| 533 | await info.count() === 1 && await info.first().isVisible()); |
| 534 | await p.evaluate(() => document.getElementById('social-info').click()); |
| 535 | await p.waitForTimeout(800); |
| 536 | const framed = await p.evaluate(() => { |
| 537 | const f = document.getElementById('web-frame'); |
| 538 | return f ? (f.getAttribute('src') || '') : '(no frame)'; |
| 539 | }); |
| 540 | check('and pressing it opens the guide page about THIS panel', |
| 541 | /guide\/social\.html$/.test(framed), framed); |
| 542 | const 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 | }); |
| 548 | check('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. |
| 555 | const 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 | }); |
| 566 | check('no stylesheet rule selects .empty-logo, which nothing draws', |
| 567 | deadRules.length === 0, deadRules.join(' | ')); |
| 568 | const drawn = await p.evaluate(() => document.querySelectorAll('.empty-logo').length); |
| 569 | check('and nothing on the page carries the class', drawn === 0, String(drawn)); |
| 570 | |
| 571 | // ── 4. A backup's version is read ──────────────────────────────────── |
| 572 | |
| 573 | const NEWER = scratch('joinedup-newer.json'); |
| 574 | const KNOWN = scratch('joinedup-known.json'); |
| 575 | fs.writeFileSync(NEWER, JSON.stringify({ |
| 576 | format: 'daimond-backup', version: 99, exported: new Date().toISOString(), |
| 577 | chats: [], workspace: [], diamonds: [], |
| 578 | })); |
| 579 | fs.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. |
| 585 | async 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 | |
| 602 | const refused = await importFile(NEWER); |
| 603 | check('a backup this build does not know is refused, out loud', |
| 604 | !!refused && /newer/i.test(refused.title), refused ? refused.title : '(no dialog)'); |
| 605 | check('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) : ''); |
| 608 | check('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. |
| 614 | if (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. |
| 620 | const accepted = await importFile(KNOWN); |
| 621 | check('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. |
| 627 | const threw = errors(s).filter(e => !/Failed to load resource|502|WebSocket|ERR_/i.test(e)); |
| 628 | check('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. |
| 641 | const GLUE = 'pkg/oxedyne_daimond.js'; |
| 642 | const 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 | |
| 645 | const glueSrc = fs.readFileSync(path.join(WWW, GLUE), 'utf8'); |
| 646 | const MODE_FN = 'export function workspace_mode() {'; |
| 647 | const READ_FN = 'export function read_file(path) {'; |
| 648 | for (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 | } |
| 655 | const 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 | |
| 662 | const PROFILE2 = scratch('pw', 'joinedup-lost' + (BREAK ? '-' + BREAK : '')); |
| 663 | fs.rmSync(PROFILE2, { recursive: true, force: true }); |
| 664 | const 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 | }); |
| 679 | try { |
| 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 | |
| 708 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 709 | if (bad.length) console.log('failed: ' + bad.join(', ')); |
| 710 | process.exit(bad.length ? 1 : 0); |