Oregami
Repositories/oxedyne/daimond

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.
59import fs from 'node:fs';
60import path from 'node:path';
61import { fileURLToPath } from 'node:url';
62import { open, shot, scratch } from './harness.mjs';
63
64const HERE = path.dirname(fileURLToPath(import.meta.url));
65const WWW = path.join(HERE, '..', 'www');
66
67const BREAK = (() => {
68 const i = process.argv.indexOf('--break');
69 return i > 0 ? String(process.argv[i + 1] || '') : '';
70})();
71
72const PROFILE = scratch('pw', 'webback' + (BREAK ? '-' + BREAK : ''));
73fs.rmSync(PROFILE, { recursive: true, force: true });
74
75const ok = [], bad = [];
76const check = (name, pass, detail) => {
77 (pass ? ok : bad).push(name);
78 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
79};
80
81// ── The breaks ───────────────────────────────────────────────────────
82const 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
113if (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.
119function 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.
136function 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
146async 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
157const s = await open({ name: 'webback', profile: PROFILE, connect: false, route });
158const { page } = s;
159
160/// What the reader's eye gets: is this control drawn at all?
161const 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.
175const framePage = () => page.evaluate(() => {
176 try { return document.getElementById('web-frame').contentWindow.location.href.replace(/^.*\/guide\//, ''); }
177 catch (e) { return 'UNREACHABLE: ' + e.name; }
178});
179
180const sleep = (ms) => new Promise(r => setTimeout(r, ms));
181
182try {
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
329if (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}
334console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
335process.exit(bad.length === 0 ? 0 : 1);