oxedyne/daimond/dev/verify_webback.mjs
16.6 KiB, 1 run
created by r2519314175:803, 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_webback.mjs — the Web panel's Back control is on screen only when |
| 2 | // pressing it does something. |
| 3 | // |
| 4 | // THE DEFECT. `#web-back` called `els.frame.contentWindow.history.back()` inside |
| 5 | // a bare `try {} catch {}`. Every path into that frame throws: |
| 6 | // |
| 7 | // * a cross-origin site — the same-origin policy, and the old comment said so; |
| 8 | // * under Daimond Hands the frame is `display:none` and still holds the guide, |
| 9 | // so a Back that worked would walk the GUIDE's history behind a header naming |
| 10 | // a live site; |
| 11 | // * AND OUR OWN GUIDE, which the reachability audit believed was the one case |
| 12 | // that worked. It is not. `#web-frame` carries |
| 13 | // `sandbox="allow-scripts allow-forms allow-popups …"` with NO |
| 14 | // `allow-same-origin` (www/index.html), deliberately — `www/guide/frame.js` |
| 15 | // opens by explaining why — so the guide sits in an OPAQUE origin and is as |
| 16 | // cross-origin to us as anybody else's site. Measured below: the read throws |
| 17 | // `SecurityError`. |
| 18 | // |
| 19 | // So the control did nothing, said nothing, and was permanently on screen. That |
| 20 | // is the failure this app's own principle is written against — a control that |
| 21 | // does nothing when pressed teaches the reader to distrust every control — so it |
| 22 | // is now drawn only where it can act. |
| 23 | // |
| 24 | // The properties, each chosen because it would be invisible if it were wrong: |
| 25 | // |
| 26 | // 1. IT IS NOT ON SCREEN WHERE IT CANNOT WORK: the guide, a cross-origin page, |
| 27 | // and the extension driver. Asserted from the COMPUTED STYLE, which is what |
| 28 | // the reader's eye gets, not from a class name. |
| 29 | // 2. AND THE REST OF THE TOOLBAR IS. Hiding the panel's controls wholesale |
| 30 | // would pass check 1 and be a worse app. |
| 31 | // 3. IT IS A PROBE, NOT A TABLE OF DRIVERS. The audit's suggested fix — show it |
| 32 | // for `guide` and `local` — would have kept a dead control on screen for the |
| 33 | // commonest case of all, because the sandbox is what blocks it and the |
| 34 | // driver does not say. Proved by GIVING the frame a browsing context we may |
| 35 | // walk (the sandbox attribute removed, which only a test may do) and |
| 36 | // watching the control come back on its own. |
| 37 | // 4. AND WHEN IT IS ON SCREEN IT WORKS: the frame really returns to the page it |
| 38 | // came from. A rule that hid the button everywhere would satisfy 1 and be |
| 39 | // indistinguishable from deleting it. |
| 40 | // |
| 41 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 42 | // damaged `www/js/web.js` to the real page through `page.route`, and the run is |
| 43 | // expected to fail. An anchor that does not appear exactly once aborts the run |
| 44 | // rather than passing quietly. |
| 45 | // |
| 46 | // node dev/verify_webback.mjs --break always # the shipped defect: never hidden |
| 47 | // node dev/verify_webback.mjs --break hidden # hidden even when it would work |
| 48 | // node dev/verify_webback.mjs --break whitelist # the audit's fix: driver table, no probe |
| 49 | // node dev/verify_webback.mjs # and then, clean |
| 50 | // |
| 51 | // eval "$(bash dev/world.sh 20 --up)" |
| 52 | // node dev/verify_webback.mjs |
| 53 | // |
| 54 | // Needs dev/serve.mjs only. No gateway and no mock model. The extension half |
| 55 | // stubs ONE function — the `chrome.runtime.sendMessage` wire — and runs the |
| 56 | // shipped code above it: `detect` adopts the stamped id, `open` takes the ext |
| 57 | // branch, `adoptPage` sets the driver and `extNote` hides the frame with the |
| 58 | // guide still inside it. |
| 59 | import fs from 'node:fs'; |
| 60 | import path from 'node:path'; |
| 61 | import { fileURLToPath } from 'node:url'; |
| 62 | import { open, shot, scratch } from './harness.mjs'; |
| 63 | |
| 64 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 65 | const WWW = path.join(HERE, '..', 'www'); |
| 66 | |
| 67 | const BREAK = (() => { |
| 68 | const i = process.argv.indexOf('--break'); |
| 69 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 70 | })(); |
| 71 | |
| 72 | const PROFILE = scratch('pw', 'webback' + (BREAK ? '-' + BREAK : '')); |
| 73 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 74 | |
| 75 | const ok = [], bad = []; |
| 76 | const check = (name, pass, detail) => { |
| 77 | (pass ? ok : bad).push(name); |
| 78 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 79 | }; |
| 80 | |
| 81 | // ── The breaks ─────────────────────────────────────────────────────── |
| 82 | const BREAKS = { |
| 83 | // The control as it shipped: bound, permanently visible, and swallowing the |
| 84 | // SecurityError every press raises. |
| 85 | always: [{ |
| 86 | file: 'js/web.js', |
| 87 | find: '\tfunction paintBack() {\n\t\tif (els.back) els.back.style.display = canGoBack() ? \'\' : \'none\';\n\t}', |
| 88 | with: '\tfunction paintBack() {\n\t\tif (els.back) els.back.style.display = \'\';\n\t}', |
| 89 | }], |
| 90 | // The opposite mistake: taken away even where it would work, which is |
| 91 | // deleting the feature rather than fixing it. |
| 92 | hidden: [{ |
| 93 | file: 'js/web.js', |
| 94 | find: '\tfunction paintBack() {\n\t\tif (els.back) els.back.style.display = canGoBack() ? \'\' : \'none\';\n\t}', |
| 95 | with: '\tfunction paintBack() {\n\t\tif (els.back) els.back.style.display = \'none\';\n\t}', |
| 96 | }], |
| 97 | // The fix the reachability audit proposed: show it for the drivers whose |
| 98 | // pages are "ours". It is wrong, and wrong in the commonest state the panel |
| 99 | // is ever in — the guide is ours and is still unreachable, because what |
| 100 | // blocks it is the sandbox and not the driver. |
| 101 | whitelist: [{ |
| 102 | file: 'js/web.js', |
| 103 | find: '\t\tif (state.driver === \'ext\' || state.driver === \'none\') return false;\n' |
| 104 | + '\t\ttry {\n' |
| 105 | + '\t\t\treturn !!(els.frame && els.frame.contentWindow && els.frame.contentWindow.history);\n' |
| 106 | + '\t\t} catch (e) {\n' |
| 107 | + '\t\t\treturn false; // opaque or cross-origin: not ours\n' |
| 108 | + '\t\t}', |
| 109 | with: '\t\treturn state.driver === \'guide\' || state.driver === \'local\';', |
| 110 | }], |
| 111 | }; |
| 112 | |
| 113 | if (BREAK && !BREAKS[BREAK]) { |
| 114 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 115 | process.exit(2); |
| 116 | } |
| 117 | |
| 118 | /// The damaged source, or a hard stop. |
| 119 | function damaged(src, spec) { |
| 120 | const n = src.split(spec.find).length - 1; |
| 121 | if (n !== 1) { |
| 122 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 123 | + 'so nothing was broken and the run below would prove nothing.'); |
| 124 | process.exit(2); |
| 125 | } |
| 126 | return src.replace(spec.find, spec.with); |
| 127 | } |
| 128 | |
| 129 | /// The damaged files, ONE BODY PER FILE. |
| 130 | /// |
| 131 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 132 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 133 | /// work and does not say so: Playwright hands a request to the LAST route |
| 134 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 135 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 136 | function damagedFiles() { |
| 137 | const byFile = new Map(); |
| 138 | for (const spec of (BREAKS[BREAK] || [])) { |
| 139 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 140 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 141 | byFile.set(spec.file, damaged(src, spec)); |
| 142 | } |
| 143 | return byFile; |
| 144 | } |
| 145 | |
| 146 | async function route(page) { |
| 147 | if (!BREAK) return; |
| 148 | for (const [file, body] of damagedFiles()) { |
| 149 | await page.route('**/' + file, r => r.fulfill({ |
| 150 | status: 200, contentType: 'application/javascript', body, |
| 151 | })); |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // ── Driving ────────────────────────────────────────────────────────── |
| 156 | |
| 157 | const s = await open({ name: 'webback', profile: PROFILE, connect: false, route }); |
| 158 | const { page } = s; |
| 159 | |
| 160 | /// What the reader's eye gets: is this control drawn at all? |
| 161 | const shown = (id) => page.evaluate((i) => { |
| 162 | const e = document.getElementById(i); |
| 163 | if (!e) return false; |
| 164 | const cs = getComputedStyle(e); |
| 165 | return cs.display !== 'none' && cs.visibility !== 'hidden' && e.offsetHeight > 0; |
| 166 | }, id); |
| 167 | |
| 168 | /// Which guide page the frame is actually showing, read from the frame's own |
| 169 | /// `location`. Only meaningful once the frame is reachable — which is the state |
| 170 | /// section 4 sets up, and the state it is asking about. |
| 171 | /// |
| 172 | /// Read through the frame rather than from the `src` attribute: `src` says what |
| 173 | /// was last ASKED for, and a Back that failed would leave it saying exactly what |
| 174 | /// a Back that worked would. |
| 175 | const framePage = () => page.evaluate(() => { |
| 176 | try { return document.getElementById('web-frame').contentWindow.location.href.replace(/^.*\/guide\//, ''); } |
| 177 | catch (e) { return 'UNREACHABLE: ' + e.name; } |
| 178 | }); |
| 179 | |
| 180 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 181 | |
| 182 | try { |
| 183 | if (BREAK) console.log(` .. running with --break ${BREAK}`); |
| 184 | |
| 185 | // ── 0. The fact the whole fix rests on ─────────────────────── |
| 186 | // Not an assumption about the same-origin policy: the read is made, from the |
| 187 | // page, in the state the app actually ships, and the error is printed. |
| 188 | await page.evaluate(() => window.DaimondWeb.guide('index.html')); |
| 189 | await page.waitForTimeout(1200); |
| 190 | const reach = await page.evaluate(() => { |
| 191 | const f = document.getElementById('web-frame'); |
| 192 | const out = { sandbox: f.getAttribute('sandbox') || '(none)', src: f.getAttribute('src') }; |
| 193 | try { out.history = 'length ' + f.contentWindow.history.length; } |
| 194 | catch (e) { out.history = e.name; } |
| 195 | return out; |
| 196 | }); |
| 197 | check('OUR OWN GUIDE is unreachable too — the sandbox has no allow-same-origin, ' |
| 198 | + 'so the frame is an opaque origin and its history throws', |
| 199 | reach.history === 'SecurityError' && !/allow-same-origin/.test(reach.sandbox), |
| 200 | `${reach.src}: sandbox="${reach.sandbox}", history → ${reach.history}`); |
| 201 | |
| 202 | // ── 1. Not on screen where it cannot work ──────────────────── |
| 203 | const driver0 = await page.evaluate(() => window.DaimondWeb.status().driver); |
| 204 | check('with the guide on screen, Back is not drawn — it could only ever be silent there', |
| 205 | !(await shown('web-back')), `driver ${driver0}`); |
| 206 | |
| 207 | // 2. And the panel did not simply lose its toolbar. |
| 208 | check('but Reload and Open-in-a-tab still are — the toolbar was not hidden wholesale', |
| 209 | (await shown('web-reload')) && (await shown('web-pop')), |
| 210 | `reload ${await shown('web-reload')}, pop ${await shown('web-pop')}`); |
| 211 | |
| 212 | // A cross-origin page. `open()` needs no network to reach the state that |
| 213 | // matters: the gateway probe fails, the frame is pointed at the site, and the |
| 214 | // driver is `frame` — which is the state the control is being asked about. |
| 215 | await page.evaluate(() => window.DaimondWeb.open('https://example.org/').catch(() => {})); |
| 216 | await page.waitForTimeout(1500); |
| 217 | const driver1 = await page.evaluate(() => window.DaimondWeb.status().driver); |
| 218 | check('with a cross-origin page framed, Back is not drawn', |
| 219 | driver1 === 'frame' && !(await shown('web-back')), `driver ${driver1}`); |
| 220 | |
| 221 | // The extension driver. The EXTENSION is stubbed — one function, the |
| 222 | // `chrome.runtime.sendMessage` wire — and everything above it is the shipped |
| 223 | // code: `detect()` adopts the stamped id, `open()` takes the ext branch, |
| 224 | // `adoptPage` sets the driver and `extNote` hides the frame while leaving the |
| 225 | // guide inside it. That last line is the defect this half is about. |
| 226 | // The frame is put back on the guide first, because that is what it holds in |
| 227 | // life: the panel's resting content, left behind when the extension takes the |
| 228 | // page into a tab of its own. |
| 229 | await page.evaluate(() => window.DaimondWeb.guide('index.html')); |
| 230 | await page.waitForTimeout(900); |
| 231 | const asExt = await page.evaluate(async () => { |
| 232 | window.chrome = window.chrome || {}; |
| 233 | chrome.runtime = { |
| 234 | lastError: null, |
| 235 | sendMessage: (id, msg, cb) => setTimeout(() => cb( |
| 236 | msg.cmd === 'ping' ? { ok: true, version: 'stub' } |
| 237 | : msg.cmd === 'open' ? { ok: true, url: msg.url, title: 'News', mode: 'agent' } |
| 238 | : msg.cmd === 'status' ? { ok: true, url: 'https://news.example/', title: 'News', mode: 'agent' } |
| 239 | : { ok: true }), 0), |
| 240 | }; |
| 241 | // Exactly how the real extension announces itself (see detect()). |
| 242 | document.documentElement.dataset.daimondHands = 'stub-hands'; |
| 243 | window.dispatchEvent(new CustomEvent('daimond-hands', { detail: { id: 'stub-hands' } })); |
| 244 | await new Promise(r => setTimeout(r, 300)); |
| 245 | try { await window.DaimondWeb.open('https://news.example/'); } catch (e) { /* refused */ } |
| 246 | const f = document.getElementById('web-frame'); |
| 247 | return { |
| 248 | driver: window.DaimondWeb.status().driver, |
| 249 | // The frame is hidden and still holds the guide: what a Back would walk. |
| 250 | frameHidden: getComputedStyle(f).display === 'none', |
| 251 | frameHolds: f.getAttribute('src') || '', |
| 252 | }; |
| 253 | }); |
| 254 | await page.waitForTimeout(600); |
| 255 | check('under Daimond Hands the frame is hidden and still holds the guide, and Back ' |
| 256 | + 'is not drawn — a Back that "worked" there would walk the GUIDE\'s history ' |
| 257 | + 'behind a header naming a live site', |
| 258 | asExt.driver === 'ext' && asExt.frameHidden && /guide\//.test(asExt.frameHolds) |
| 259 | && !(await shown('web-back')), |
| 260 | `driver ${asExt.driver}, frame hidden ${asExt.frameHidden}, holding ${asExt.frameHolds}`); |
| 261 | |
| 262 | // ── 3 and 4. The rule is a probe, and the control works ────── |
| 263 | // Give the frame a browsing context we may walk. Only a test may do this: the |
| 264 | // sandbox is the app's isolation of pages an agent wrote, and removing it in |
| 265 | // shipped code would hand a written page the user's keys. Here it stands in |
| 266 | // for any future change that makes the frame reachable — a guide frame of its |
| 267 | // own, a back-channel through guide/frame.js — and asks whether the control |
| 268 | // notices without anyone editing this rule. |
| 269 | await page.evaluate(() => { |
| 270 | document.getElementById('web-frame').removeAttribute('sandbox'); |
| 271 | window.DaimondWeb.guide('index.html'); |
| 272 | }); |
| 273 | await page.waitForTimeout(1500); |
| 274 | const reach2 = await page.evaluate(() => { |
| 275 | try { return 'length ' + document.getElementById('web-frame').contentWindow.history.length; } |
| 276 | catch (e) { return e.name; } |
| 277 | }); |
| 278 | check('give the frame a history we may walk and the control comes back BY ITSELF — ' |
| 279 | + 'the rule asks the frame, it does not consult a table of drivers', |
| 280 | (await shown('web-back')) && /^length/.test(reach2), `history → ${reach2}`); |
| 281 | |
| 282 | // And it really goes back. The frame is navigated to a second guide page, and |
| 283 | // the assertion is the PAGE it lands on, not that a click was accepted. |
| 284 | const from = await framePage(); |
| 285 | // Navigated by CLICKING THE GUIDE'S OWN LINK, inside the frame, which is how a |
| 286 | // reader gets a second page into it. (`location.assign` from out here would |
| 287 | // resolve the relative url against the APP's document, not the frame's — it |
| 288 | // lands on the app root and looks like a broken guide.) |
| 289 | await page.evaluate(() => { |
| 290 | const d = document.getElementById('web-frame').contentWindow.document; |
| 291 | const a = d.querySelector('a[href="spending.html"]'); |
| 292 | if (a) a.click(); else d.location.href = 'spending.html'; |
| 293 | }); |
| 294 | // Waited for by NAME rather than by a fixed sleep: a frame caught mid-flight |
| 295 | // has no url yet, and a check that read one would be measuring the clock. |
| 296 | await page.waitForFunction( |
| 297 | () => /\/guide\/spending/.test(document.getElementById('web-frame').contentWindow.location.href), |
| 298 | null, { timeout: 15000 }).catch(() => {}); |
| 299 | await sleep(400); |
| 300 | const moved = await framePage(); |
| 301 | check('the frame moved to a second guide page, so there is somewhere to go back to', |
| 302 | /^index\.html/.test(from) && /^spending\.html/.test(moved), `${from} → ${moved}`); |
| 303 | |
| 304 | // Pressed only if it is there to press. A run that threw on the click would |
| 305 | // report "the run finished" and hide which property failed. |
| 306 | const pressable = await shown('web-back'); |
| 307 | if (pressable) await page.click('#web-back', { force: true }); |
| 308 | await page.waitForFunction( |
| 309 | () => /\/guide\/index/.test(document.getElementById('web-frame').contentWindow.location.href), |
| 310 | null, { timeout: 15000 }).catch(() => {}); |
| 311 | await sleep(400); |
| 312 | const landed = await framePage(); |
| 313 | // Named pages, not "it changed": a frame that fell back to about:blank, or one |
| 314 | // this check could not read at all, would otherwise pass as a working Back. |
| 315 | check('AND PRESSING BACK RETURNS IT to the page it came from — the control is drawn ' |
| 316 | + 'only where it acts, and where it is drawn it acts', |
| 317 | pressable && /^spending\.html/.test(moved) && /^index\.html/.test(landed), |
| 318 | pressable ? `${from} → ${moved} → back → ${landed}` |
| 319 | : 'the control was not on screen to press, in the one state where it works'); |
| 320 | |
| 321 | await shot(s, 'webback' + (BREAK ? '-' + BREAK : '')); |
| 322 | } catch (e) { |
| 323 | check('the run finished', false, String((e && e.message) || e)); |
| 324 | try { await shot(s, 'webback-threw'); } catch { /* nothing to show */ } |
| 325 | } finally { |
| 326 | await s.close(); |
| 327 | } |
| 328 | |
| 329 | if (BREAK) { |
| 330 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 331 | + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 332 | process.exit(bad.length ? 0 : 1); // a break MUST fail something |
| 333 | } |
| 334 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 335 | process.exit(bad.length === 0 ? 0 : 1); |