oxedyne/daimond/dev/verify_autoreload.mjs
24.6 KiB, 1 run
created by r2519314175:249, 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_autoreload.mjs — the standing instruction to buy your own credits. |
| 2 | // |
| 3 | // This one is driven against the REAL gateway on :9002, not a stub, because the thing worth |
| 4 | // proving is not that the form renders. It is that the gateway REFUSES the settings that cannot |
| 5 | // work, and that the browser shows the refusal rather than pretending it saved: |
| 6 | // |
| 7 | // * on, with no card -> 422. There is nothing to charge, and the user would not find out |
| 8 | // until the balance ran dry. |
| 9 | // * budget < one top-up -> 400. The first reload already crosses the cap, so the setting |
| 10 | // would look on and never act. |
| 11 | // |
| 12 | // Both are the gateway's rules. The browser must not have its own copy of them — it must ask, and |
| 13 | // say what it is told. A client that guesses is a client that will one day guess differently from |
| 14 | // the till. |
| 15 | // |
| 16 | // AND THE PANEL HAS TO SAY WHAT IT WAS TOLD. Everything above was asserted at the API for a |
| 17 | // month while the panel's own message line could not print a word: `note()` looked up an ID |
| 18 | // `ar-note` that was only ever a CLASS, so `getElementById` returned null, the guard swallowed |
| 19 | // every message, and four call sites about money -- a card that would not save, a gateway that |
| 20 | // refused the settings -- went quiet. Nothing caught it, because an element that does not exist |
| 21 | // reports itself to a browser locator as HIDDEN, which is exactly what a working guard produces. |
| 22 | // So the checks here assert the line EXISTS and says the gateway's own sentence; never that no |
| 23 | // error is showing. |
| 24 | // |
| 25 | // AND THE PANEL HAS TO BE THERE AT ALL. The last section drives a session whose gateway |
| 26 | // bootstrap is still in flight when Credits is opened, which is the state a loaded machine |
| 27 | // produces by itself. Proved red first: |
| 28 | // |
| 29 | // AND THE SAVE BUTTON HAS TO BE WIRED TO ANYTHING AT ALL. Driving the panel through the button |
| 30 | // rather than the API found a third fault under the first two: `render()` held a local |
| 31 | // `var save` for the card button, `var` is function-scoped, and so the module's `save()` was |
| 32 | // shadowed where the Save button binds to it. `addEventListener` was handed a DOM element on one |
| 33 | // branch and `undefined` on the other, accepted both without a word, and the button did NOTHING. |
| 34 | // Every check that spoke to the API passed throughout. |
| 35 | // |
| 36 | // node dev/verify_autoreload.mjs --break latefill # the late fill is caught |
| 37 | // node dev/verify_autoreload.mjs --break noteid # the class-for-an-id trap is caught |
| 38 | // node dev/verify_autoreload.mjs --break noteorder # the note eaten by its own redraw |
| 39 | // node dev/verify_autoreload.mjs --break deadsave # the Save button wired to nothing |
| 40 | // node dev/verify_autoreload.mjs # and then, clean |
| 41 | import fs from 'node:fs'; |
| 42 | import path from 'node:path'; |
| 43 | import { fileURLToPath } from 'node:url'; |
| 44 | import { open, shot, errors } from './harness.mjs'; |
| 45 | |
| 46 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 47 | const AR_JS = path.join(HERE, '..', 'www', 'js', 'autoreload.js'); |
| 48 | |
| 49 | const BREAK = (() => { |
| 50 | const i = process.argv.indexOf('--break'); |
| 51 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 52 | })(); |
| 53 | const die = (why) => { console.error('ABORT: ' + why); process.exit(2); }; |
| 54 | |
| 55 | // ── The patches that prove the message-line checks can fail ──────────── |
| 56 | // |
| 57 | // Each is [anchor, replacement] against the shipped `www/js/autoreload.js`, applied to the file |
| 58 | // the browser fetches. An anchor that no longer appears is a broken proof and not a passing one, |
| 59 | // so it is checked against the file on disk before a browser is opened. |
| 60 | // |
| 61 | // noteid the exact defect that shipped: the id put back to being only a class, so |
| 62 | // `getElementById('ar-note')` is null and every message is swallowed by the guard. |
| 63 | // noteorder the SECOND defect on the same path: the confirmation written before `render()` |
| 64 | // rather than after, so the redraw destroys it in the same tick. Fixing either one |
| 65 | // alone leaves the panel silent, which is why both are proved separately. |
| 66 | const BREAKS = { |
| 67 | noteid: { |
| 68 | what: 'the note div given a class where an id was wanted, exactly as it shipped', |
| 69 | patch: ["\t\tnoteEl.id = 'ar-note';\n", ''], |
| 70 | }, |
| 71 | deadsave: { |
| 72 | what: 'the card button named `save` again, shadowing the handler the Save button binds to', |
| 73 | patch: [ |
| 74 | "var addCard = el('button', 'ar-card-btn accent', t('autoreload.save_card'));\n" |
| 75 | + "\t\t\taddCard.title = t('autoreload.save_card_help');\n" |
| 76 | + "\t\t\taddCard.addEventListener('click', startCard);\n" |
| 77 | + "\t\t\tcardRow.appendChild(addCard);", |
| 78 | "var save = el('button', 'ar-card-btn accent', t('autoreload.save_card'));\n" |
| 79 | + "\t\t\tsave.title = t('autoreload.save_card_help');\n" |
| 80 | + "\t\t\tsave.addEventListener('click', startCard);\n" |
| 81 | + "\t\t\tcardRow.appendChild(save);", |
| 82 | ], |
| 83 | }, |
| 84 | noteorder: { |
| 85 | what: 'the confirmation written before the redraw that wipes it', |
| 86 | patch: [ |
| 87 | "\t\t\tawait render();\n" |
| 88 | + "\t\t\tnote(next.enabled ? t('autoreload.on_note') : t('autoreload.off_note'));\n", |
| 89 | "\t\t\tnote(next.enabled ? t('autoreload.on_note') : t('autoreload.off_note'));\n" |
| 90 | + "\t\t\tawait render();\n", |
| 91 | ], |
| 92 | }, |
| 93 | }; |
| 94 | if (BREAK && BREAK !== 'latefill' && !BREAKS[BREAK]) die(`no break called "${BREAK}"`); |
| 95 | |
| 96 | /// The damaged `autoreload.js`, or null when nothing is being broken here. |
| 97 | const hurtSrc = (() => { |
| 98 | if (!BREAKS[BREAK]) return null; |
| 99 | const src = fs.readFileSync(AR_JS, 'utf8'); |
| 100 | const [from, to] = BREAKS[BREAK].patch; |
| 101 | if (!src.includes(from)) die(`the "${BREAK}" break no longer matches autoreload.js`); |
| 102 | return src.split(from).join(to); |
| 103 | })(); |
| 104 | if (hurtSrc) console.log(`BREAK ${BREAK}: ${BREAKS[BREAK].what}\n`); |
| 105 | |
| 106 | const ok = [], bad = []; |
| 107 | const check = (name, pass, detail) => { |
| 108 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 109 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 110 | }; |
| 111 | |
| 112 | const s = await open({ |
| 113 | name: 'autoreload', connect: false, |
| 114 | route: hurtSrc ? async (pg) => { |
| 115 | await pg.route('**/js/autoreload.js', (r) => |
| 116 | r.fulfill({ status: 200, contentType: 'text/javascript', body: hurtSrc })); |
| 117 | } : null, |
| 118 | }); |
| 119 | const p = s.page; |
| 120 | await p.waitForTimeout(2000); |
| 121 | |
| 122 | // The gateway must be up, or nothing below means anything. |
| 123 | const up = await p.evaluate(async () => { |
| 124 | const r = await fetch('/api/autoreload', { credentials: 'same-origin' }); |
| 125 | return { status: r.status }; |
| 126 | }); |
| 127 | check('the gateway is up and the route is reachable', |
| 128 | up.status === 200 || up.status === 401, 'HTTP ' + up.status); |
| 129 | |
| 130 | // Open Credits — the panel lives with the credits, not in a settings page of its own. |
| 131 | await p.evaluate(() => { |
| 132 | const row = document.getElementById('astat-credits') |
| 133 | || [...document.querySelectorAll('.astat-row')].find(r => /credit/i.test(r.textContent)); |
| 134 | if (row) row.click(); |
| 135 | }); |
| 136 | // Wait for the panel, rather than sleeping at where it ought to be. |
| 137 | // |
| 138 | // This was a flat 1800ms, and on the gate of 2026-08-13 that was not enough: the gateway |
| 139 | // session is taken asynchronously at boot — an account POST, a challenge, a signature, a |
| 140 | // verify — and on a loaded machine it had not landed when the panel was read. Five checks |
| 141 | // went red, including the self-test below, which correctly reported that there was nothing |
| 142 | // for it to blind. A fixed sleep made this file's verdict a measurement of how busy the |
| 143 | // machine was. |
| 144 | // |
| 145 | // BOUNDED, so a panel that never arrives still fails, and fails saying so. Waiting for the |
| 146 | // condition is not the same as assuming it: the case where the session lands AFTER Credits |
| 147 | // was opened is a property in its own right, and it is proved at the foot of this file. |
| 148 | await p.waitForFunction(() => { |
| 149 | const h = document.getElementById('autoreload'); |
| 150 | return !!h && h.textContent.trim().length > 0; |
| 151 | }, null, { timeout: 15000 }).catch(() => { /* the check below reports it */ }); |
| 152 | |
| 153 | const panel = await p.evaluate(() => { |
| 154 | const h = document.getElementById('autoreload'); |
| 155 | return { |
| 156 | there: !!h && h.textContent.trim().length > 0, |
| 157 | text: h ? h.textContent : '', |
| 158 | // The LEAD, on its own. The panel's own furniture says "card" and "budget" |
| 159 | // in its labels, so a claim tested against the whole panel is answered by |
| 160 | // the widgets and passes whether or not a sentence explains anything. |
| 161 | lead: (h && h.querySelector('.cfg-lead') || {}).textContent || '', |
| 162 | hasCard: !!document.querySelector('.ar-card-has'), |
| 163 | noCard: !!document.querySelector('.ar-card-none'), |
| 164 | switchDisabled: !!(document.getElementById('ar-on') || {}).disabled, |
| 165 | }; |
| 166 | }); |
| 167 | check('auto-reload appears inside Credits, where the balance is', |
| 168 | panel.there, panel.there ? '(rendered)' : '(empty — is the account authed?)'); |
| 169 | check('a fresh account is told it has no card', panel.noCard === true); |
| 170 | check('and the switch cannot be turned on without one', |
| 171 | panel.switchDisabled === true, |
| 172 | panel.switchDisabled ? 'disabled, and says why' : 'ENABLED WITH NO CARD'); |
| 173 | // ── The panel explains itself ────────────────────────────────────────── |
| 174 | // |
| 175 | // A standing instruction to spend the user's money must SAY, before it is armed, the three |
| 176 | // things a person would otherwise find out from a statement: that the money comes off the |
| 177 | // CARD, that it happens WITHOUT THEM BEING ASKED, and that their own LIMIT bounds it. |
| 178 | // |
| 179 | // Those three facts are the property. The sentence carrying them is not: this check used to |
| 180 | // match `charges the card below, without asking` verbatim and went red on 2026-08-11 when a |
| 181 | // concision pass over 222 catalogue strings dropped two commas — the panel explained itself |
| 182 | // exactly as well before and after. A check that cannot survive a comma is measuring the |
| 183 | // author, not the product. |
| 184 | const SAYS = { |
| 185 | 'what is charged': /\bcards?\b/i, |
| 186 | 'that it is unasked': /without (being )?ask|without asking|automatic|on its own|by itself|unprompted/i, |
| 187 | 'the user’s ceiling': /\b(limit|cap|ceiling|budget|no more than|up to)\b/i, |
| 188 | 'that it spends': /\bcharg|\bbuy|\bbuys\b|\bbought\b|top[- ]?up|purchas/i, |
| 189 | }; |
| 190 | const explains = (s) => Object.entries(SAYS).filter(([, re]) => !re.test(String(s || ''))).map(([k]) => k); |
| 191 | const missing = explains(panel.lead); |
| 192 | check('the panel says what it will do, in words', |
| 193 | panel.lead.trim().length > 0 && missing.length === 0, |
| 194 | missing.length ? 'the lead never says: ' + missing.join(', ') + ' — “' + panel.lead + '”' |
| 195 | : '“' + panel.lead.trim() + '”'); |
| 196 | |
| 197 | // And the same check, over copy that genuinely explains nothing — read back out of the DOM |
| 198 | // by the same path, so what is proved red is the whole check and not just its regexes. |
| 199 | const blinded = await p.evaluate(() => { |
| 200 | const el = document.querySelector('#autoreload .cfg-lead'); |
| 201 | if (!el) return null; |
| 202 | const was = el.textContent; |
| 203 | el.textContent = 'Auto-reload settings.'; |
| 204 | const seen = (document.querySelector('#autoreload .cfg-lead') || {}).textContent || ''; |
| 205 | el.textContent = was; |
| 206 | return { seen, restored: (document.querySelector('#autoreload .cfg-lead') || {}).textContent }; |
| 207 | }); |
| 208 | check('and it is a check that can fail — a lead that explains nothing is caught', |
| 209 | !!blinded && explains(blinded.seen).length === Object.keys(SAYS).length |
| 210 | && blinded.restored === panel.lead, |
| 211 | blinded ? 'a bare “' + blinded.seen + '” is missing all ' + Object.keys(SAYS).length |
| 212 | : 'there was no lead paragraph to blind'); |
| 213 | |
| 214 | await shot(s, 'autoreload'); |
| 215 | |
| 216 | // ── The gateway's refusals, seen from the browser ────────────────────── |
| 217 | // |
| 218 | // The switch is disabled in the UI, so the "on with no card" case is forced at the API — which is |
| 219 | // where it must hold anyway, since a client can always be lied to but the gateway cannot. |
| 220 | |
| 221 | const noCard = await p.evaluate(async () => { |
| 222 | const r = await fetch('/api/autoreload', { |
| 223 | method: 'POST', credentials: 'same-origin', |
| 224 | headers: { 'content-type': 'application/json' }, |
| 225 | body: JSON.stringify({ enabled: true, threshold_minor: 500, topup_minor: 2000, monthly_budget_minor: 10000 }), |
| 226 | }); |
| 227 | return { status: r.status, body: await r.text() }; |
| 228 | }); |
| 229 | check('turning it on with no card is refused by the gateway', |
| 230 | noCard.status === 422, 'HTTP ' + noCard.status); |
| 231 | check('and the refusal says what to do about it', |
| 232 | /save a card/i.test(noCard.body), noCard.body.slice(0, 90)); |
| 233 | |
| 234 | const tooSmall = await p.evaluate(async () => { |
| 235 | const r = await fetch('/api/autoreload', { |
| 236 | method: 'POST', credentials: 'same-origin', |
| 237 | headers: { 'content-type': 'application/json' }, |
| 238 | // Off, so the card rule does not fire first — but the budget is still nonsense. |
| 239 | body: JSON.stringify({ enabled: false, threshold_minor: 500, topup_minor: 2000, monthly_budget_minor: 1000 }), |
| 240 | }); |
| 241 | return { status: r.status, body: await r.text() }; |
| 242 | }); |
| 243 | // With `enabled: false` the gateway saves it: an instruction that is off cannot misfire, and |
| 244 | // refusing to let a user write down a plan before switching it on would be officious. |
| 245 | check('settings that are OFF are saved without argument, nonsense or not', |
| 246 | tooSmall.status === 200, 'HTTP ' + tooSmall.status); |
| 247 | |
| 248 | // The same nonsense, ON. It is refused — but for the CARD, not the budget: the card is asked |
| 249 | // about first, because it is the refusal the user can act on. Told the budget was too small while |
| 250 | // also having no card, they would fix the budget and be refused all over again. |
| 251 | // |
| 252 | // Which means the budget rule is unreachable from here, and from any browser test: it lies behind |
| 253 | // the card check, and no test can put a real card on a real Stripe customer. It is therefore |
| 254 | // tested where it CAN be — `autoreload::tests::test_a_budget_under_one_topup_is_refused`, against |
| 255 | // the pure `refuse()` the handler calls. This check exists to pin the ORDER, so that if the two |
| 256 | // rules are ever swapped, this fails and says so. |
| 257 | const nonsenseOn = await p.evaluate(async () => { |
| 258 | const r = await fetch('/api/autoreload', { |
| 259 | method: 'POST', credentials: 'same-origin', |
| 260 | headers: { 'content-type': 'application/json' }, |
| 261 | body: JSON.stringify({ enabled: true, threshold_minor: 500, topup_minor: 2000, monthly_budget_minor: 1000 }), |
| 262 | }); |
| 263 | return { status: r.status, body: await r.text() }; |
| 264 | }); |
| 265 | check('switching on with no card complains about the CARD, not the budget — the fixable thing first', |
| 266 | nonsenseOn.status === 422 && /saved card/i.test(nonsenseOn.body), |
| 267 | 'HTTP ' + nonsenseOn.status + ' — ' + nonsenseOn.body.slice(0, 60)); |
| 268 | |
| 269 | // ── What is saved is what comes back ────────────────────────────────── |
| 270 | |
| 271 | const roundTrip = await p.evaluate(async () => { |
| 272 | await fetch('/api/autoreload', { |
| 273 | method: 'POST', credentials: 'same-origin', |
| 274 | headers: { 'content-type': 'application/json' }, |
| 275 | body: JSON.stringify({ enabled: false, threshold_minor: 750, topup_minor: 2500, monthly_budget_minor: 15000 }), |
| 276 | }); |
| 277 | const r = await fetch('/api/autoreload', { credentials: 'same-origin' }); |
| 278 | return await r.json(); |
| 279 | }); |
| 280 | check('what is written is what is read back', |
| 281 | roundTrip.threshold_minor === 750 && roundTrip.topup_minor === 2500 |
| 282 | && roundTrip.monthly_budget_minor === 15000 && roundTrip.enabled === false, |
| 283 | `${roundTrip.threshold_minor}/${roundTrip.topup_minor}/${roundTrip.monthly_budget_minor} enabled=${roundTrip.enabled}`); |
| 284 | check('and the month\'s spend is reported against the ceiling', |
| 285 | typeof roundTrip.spent_this_month_minor === 'number', |
| 286 | 'spent ' + roundTrip.spent_this_month_minor + ' of ' + roundTrip.monthly_budget_minor); |
| 287 | check('the card is always the same shape, never null', |
| 288 | roundTrip.card && typeof roundTrip.card.saved === 'boolean', |
| 289 | JSON.stringify(roundTrip.card)); |
| 290 | |
| 291 | // The panel must show what the gateway now holds, not what it drew before. |
| 292 | await p.evaluate(() => window.DaimondAutoReload.render()); |
| 293 | await p.waitForTimeout(900); |
| 294 | const shown = await p.evaluate(() => { |
| 295 | const v = id => (document.getElementById(id) || {}).value; |
| 296 | return { th: v('ar-threshold'), tu: v('ar-topup'), bu: v('ar-budget') }; |
| 297 | }); |
| 298 | check('the panel shows the saved numbers, in whole units', |
| 299 | shown.th === '7.5' && shown.tu === '25' && shown.bu === '150', |
| 300 | `${shown.th} / ${shown.tu} / ${shown.bu}`); |
| 301 | |
| 302 | // ── The panel SAYS what the gateway said ─────────────────────────────── |
| 303 | // |
| 304 | // Everything above this point is measured at the API. That is where the RULES live, and it is |
| 305 | // also how this file could report a healthy panel for a month while the panel could not print a |
| 306 | // word: `note()` asked for an element by an id that was only ever a class name, so it found |
| 307 | // null every time and returned quietly. A user saving nonsense was shown nothing whatever. |
| 308 | // |
| 309 | // The first check here is therefore about EXISTENCE, and it is the one that matters. An element |
| 310 | // that is missing and an element that is correctly empty look identical to a locator -- both |
| 311 | // hidden -- so a check written as "no error is showing" passes on a panel that has been struck |
| 312 | // dumb. Presence first, then content, and never absence alone. |
| 313 | |
| 314 | const line = await p.evaluate(() => { |
| 315 | const n = document.getElementById('ar-note'); |
| 316 | const h = document.getElementById('autoreload'); |
| 317 | return { |
| 318 | there: !!n, |
| 319 | tag: n ? n.tagName.toLowerCase() : '', |
| 320 | inPanel: !!(n && h && h.contains(n)), |
| 321 | live: n ? n.getAttribute('aria-live') : '', |
| 322 | }; |
| 323 | }); |
| 324 | check('the panel HAS a message line, under the id the code looks it up by', |
| 325 | line.there && line.inPanel, |
| 326 | line.there ? `<${line.tag}> inside #autoreload, aria-live="${line.live}"` |
| 327 | : 'NO #ar-note — every message this panel can make is swallowed by its own guard'); |
| 328 | |
| 329 | // A save the gateway ACCEPTS. Its confirmation has to survive the redraw the save itself sets |
| 330 | // off: `render()` blanks the host and re-appends an EMPTY note line, so a message written |
| 331 | // before it is destroyed in the same tick and the user is told nothing at all. |
| 332 | const okSave = await p.evaluate(() => { |
| 333 | const want = window.DaimondI18n ? DaimondI18n.t('autoreload.off_note') : ''; |
| 334 | const box = document.getElementById('ar-on'); |
| 335 | if (box) box.checked = false; // off is saved without argument |
| 336 | const btn = document.getElementById('ar-save'); |
| 337 | if (btn) btn.click(); |
| 338 | return { want, clicked: !!btn }; |
| 339 | }); |
| 340 | check('there is a Save button to drive', okSave.clicked === true); |
| 341 | const settled = await p.waitForFunction(() => { |
| 342 | const b = document.getElementById('ar-save'); |
| 343 | const n = document.getElementById('ar-note'); |
| 344 | return !!b && !b.disabled && !!n && n.textContent.trim().length > 0; |
| 345 | }, null, { timeout: 8000 }).then(() => true).catch(() => false); |
| 346 | await p.waitForTimeout(800); // and then the panel is left alone a moment |
| 347 | const said = await p.evaluate(() => { |
| 348 | const n = document.getElementById('ar-note'); |
| 349 | return { |
| 350 | there: !!n, |
| 351 | text: n ? n.textContent.trim() : '', |
| 352 | bad: !!(n && n.classList.contains('bad')), |
| 353 | }; |
| 354 | }); |
| 355 | check('a save the gateway accepts is confirmed, and the confirmation outlives its own redraw', |
| 356 | settled && said.there && said.text.length > 0 && said.text === okSave.want && !said.bad, |
| 357 | said.there ? `“${said.text || '(empty — the redraw ate the confirmation)'}”` |
| 358 | : 'there is no message line at all'); |
| 359 | |
| 360 | // And a save the gateway REFUSES. The switch is disabled without a card, so the refusal is |
| 361 | // reached the way a client that ignored the disabled attribute would reach it — which is the |
| 362 | // case the panel was silent about: a 422 carrying a sentence written for a person to read, |
| 363 | // thrown away by a guard that looked like it was working. |
| 364 | const refusal = (() => { |
| 365 | try { return String(JSON.parse(noCard.body).error || ''); } catch (e) { return ''; } |
| 366 | })(); |
| 367 | check('the gateway’s refusal is a sentence a person can act on, not a code', |
| 368 | refusal.length > 20 && /card/i.test(refusal), `“${refusal}”`); |
| 369 | |
| 370 | await p.evaluate(() => { |
| 371 | const box = document.getElementById('ar-on'); |
| 372 | if (box) box.checked = true; // there is no card; the gateway will refuse |
| 373 | const btn = document.getElementById('ar-save'); |
| 374 | if (btn) btn.click(); |
| 375 | }); |
| 376 | const heard = await p.waitForFunction((want) => { |
| 377 | const n = document.getElementById('ar-note'); |
| 378 | return !!n && n.textContent.trim() === want; |
| 379 | }, refusal, { timeout: 8000 }).then(() => true).catch(() => false); |
| 380 | await p.waitForTimeout(900); // the panel settles; the refusal must remain |
| 381 | const after = await p.evaluate(() => { |
| 382 | const n = document.getElementById('ar-note'); |
| 383 | return { |
| 384 | there: !!n, |
| 385 | text: n ? n.textContent.trim() : '', |
| 386 | bad: !!(n && n.classList.contains('bad')), |
| 387 | seen: !!(n && n.offsetParent !== null), |
| 388 | }; |
| 389 | }); |
| 390 | check('the gateway’s refusal is SHOWN, in the gateway’s own words', |
| 391 | heard && after.there && after.text === refusal, |
| 392 | after.there ? `“${after.text || '(the panel said nothing)'}”` : 'there is no message line at all'); |
| 393 | check('and it reads as a failure, and is still on screen once the panel has settled', |
| 394 | after.bad && after.seen && after.text === refusal, |
| 395 | `bad=${after.bad} on-screen=${after.seen}`); |
| 396 | await shot(s, 'autoreload-note'); |
| 397 | |
| 398 | // The 422s are the refusals this test went looking for, so they are not faults — the browser logs |
| 399 | // every non-2xx fetch as a console error. |
| 400 | // 402 joins the ambient list: a free account's idle sync push is refused |
| 401 | // (sync is Pro), which the browser logs while this test is about auto-reload. |
| 402 | const errs = errors(s).filter(e => !/favicon|404|401|402|422|Unprocessable|net::ERR/.test(e)); |
| 403 | console.log('\nconsole errors:', errs.slice(0, 4)); |
| 404 | check('nothing throws', errs.length === 0, errs[0] || ''); |
| 405 | |
| 406 | await s.close(); |
| 407 | |
| 408 | // ── A session that lands AFTER Credits was opened fills the panel in ──── |
| 409 | // |
| 410 | // Everything above is measured on a session that had already landed. This is the case where |
| 411 | // it has not, and until 2026-08-13 it was a permanent blank: `render` draws nothing without |
| 412 | // `state.authed`, the flag is set five round trips into the boot, and NOTHING redrew the |
| 413 | // panel when it flipped. A user who reached Credits a second early was shown an empty space |
| 414 | // where the standing instruction to spend their money should be, for the life of the page. |
| 415 | // The only way back was a reload, and nothing on screen said so. |
| 416 | // |
| 417 | // That is also what made the gate red rather than this file: the failure arrived as a timing |
| 418 | // flake, so it read as a slow machine instead of a missing panel. |
| 419 | // |
| 420 | // Driven by holding the LAST leg of the bootstrap, so the app is in exactly the state a |
| 421 | // loaded machine puts it in: unlocked, drawn, and not yet authed. The precondition is |
| 422 | // ASSERTED and not assumed — a run where the session beat the click proves nothing, and must |
| 423 | // say so rather than going green. |
| 424 | const HOLD_MS = 8000; |
| 425 | const late = await open({ |
| 426 | name: 'autoreload-late', connect: false, |
| 427 | route: async (pg) => { |
| 428 | await pg.route('**/api/auth/verify', async (r) => { |
| 429 | await new Promise((f) => setTimeout(f, HOLD_MS)); |
| 430 | await r.continue(); |
| 431 | }); |
| 432 | if (BREAK === 'latefill') { |
| 433 | // The listener taken back out, and only that: the panel is drawn by |
| 434 | // exactly the code that drew it before, and nothing redraws it late. |
| 435 | const src = fs.readFileSync(AR_JS, 'utf8'); |
| 436 | const hurt = src.replace(/\twindow\.addEventListener\('daimond:authed'[\s\S]*?\n\t\}\);\n/, ''); |
| 437 | if (hurt === src) die('the latefill break did not reach the daimond:authed listener'); |
| 438 | await pg.route('**/js/autoreload.js', (r) => |
| 439 | r.fulfill({ status: 200, contentType: 'text/javascript', body: hurt })); |
| 440 | } |
| 441 | }, |
| 442 | }); |
| 443 | const lp = late.page; |
| 444 | await lp.waitForTimeout(1500); |
| 445 | await lp.evaluate(() => { |
| 446 | const row = document.getElementById('astat-credits') |
| 447 | || [...document.querySelectorAll('.astat-row')].find(r => /credit/i.test(r.textContent)); |
| 448 | if (row) row.click(); |
| 449 | }); |
| 450 | await lp.waitForTimeout(600); |
| 451 | const early = await lp.evaluate(() => { |
| 452 | const h = document.getElementById('autoreload'); |
| 453 | return { |
| 454 | empty: !h || h.textContent.trim().length === 0, |
| 455 | authed: !!(window.DaimondGateway && DaimondGateway.state().authed), |
| 456 | }; |
| 457 | }); |
| 458 | // The panel AND the session, so a blank that stays blank cannot be read as a session that |
| 459 | // never came. |
| 460 | const filled = await lp.waitForFunction(() => { |
| 461 | const h = document.getElementById('autoreload'); |
| 462 | return !!h && !!h.querySelector('.cfg-lead') |
| 463 | && !!(window.DaimondGateway && DaimondGateway.state().authed); |
| 464 | }, null, { timeout: 20000 }).then(() => true).catch(() => false); |
| 465 | const lateAuthed = await lp.evaluate(() => |
| 466 | !!(window.DaimondGateway && DaimondGateway.state().authed)); |
| 467 | check('Credits opened before the session lands is filled in when it does', |
| 468 | early.empty && !early.authed && filled, |
| 469 | !early.empty || early.authed |
| 470 | ? 'nothing was proved: the session had already landed when Credits was opened' |
| 471 | : filled ? 'blank while unauthed, drawn once authed' |
| 472 | : `still blank ${lateAuthed ? 'WITH' : 'without'} a session, 20s on`); |
| 473 | await shot(late, 'autoreload-late'); |
| 474 | await late.close(); |
| 475 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 476 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 477 | if (BREAK) { |
| 478 | if (bad.length) { console.log('the break was caught, as it should be'); process.exit(0); } |
| 479 | console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing'); |
| 480 | process.exit(1); |
| 481 | } |
| 482 | process.exit(bad.length ? 1 : 0); |