Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_burger.mjs

10.3 KiB, 1 run

created by r2519314175:257, 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_burger.mjs — the phone hamburger still opens the drawer after the
2// phone has been turned on its side and back.
3//
4// Reported from an iPhone: "after the modestly long chat use, the main hamburger
5// icon left in the header for mobile view stops working". Long use is the
6// EXPOSURE, not the cause. The transcript's size does not matter and neither does
7// the session's age; what matters is that somewhere in a long session the phone
8// was rotated once.
9//
10// An iPhone in landscape is 844 CSS pixels wide. That is outside the 760 the
11// phone shell is bounded by and far inside the 1280 at which the desktop layout
12// folds the rail away, so `DaimondPanels.apply()` takes its DESKTOP branch and
13// writes an inline `display: none` on `#panel-rail`. Back in portrait the phone
14// branch runs — and it used to do nothing at all, so the inline rule survived.
15//
16// The rail is the one panel that rule can kill. `responsive.css` decides every
17// other panel's phone visibility with `display: … !important` and excludes
18// `.rail` by name, because the rail is the DRAWER and is shown by
19// `body.drawer-open` plus a transform. Nothing overrules the inline style, so the
20// hamburger — which only toggles that class — opened a drawer that was not drawn.
21// Diamonds, Chats and Admin were then unreachable until the page was reloaded.
22//
23// node dev/verify_burger.mjs --break noelse # the phone branch does nothing
24// node dev/verify_burger.mjs --break nomq # `apply()` is never re-run on the flip
25// node dev/verify_burger.mjs # and then, clean
26//
27// `dev/probe_burger.mjs` is the diagnostic this came out of: it drives the same
28// rotation on WebKit at an iPhone viewport, and its `--arm size` and `--arm time`
29// are the two arms that come back clean, which is how the rotation was found.
30import fs from 'node:fs';
31import path from 'node:path';
32import { fileURLToPath } from 'node:url';
33import { open } from './harness.mjs';
34
35const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www');
36
37const BREAK = (() => {
38 const i = process.argv.indexOf('--break');
39 return i > 0 ? String(process.argv[i + 1] || '') : '';
40})();
41
42// Each break is a real edit to a real file, served in place of it through
43// `page.route`, and is how that piece behaved before the fix.
44const BREAKS = {
45 // The phone branch of `apply()`, back to the empty one it was: nothing puts
46 // the rail's `display` back, so the landscape leg's inline `none` stands.
47 //
48 // Anchored on the branch's opening and its closing brace rather than on its
49 // text, which is a comment somebody will improve.
50 noelse: [{
51 file: 'js/daimond.js',
52 re: /\t\t\t\} else \{\n\t\t\t\t\/\/ AND THE RAIL IS PUT BACK[\s\S]*?\n\t\t\t\}/,
53 with: '\t\t\t}',
54 }],
55 // The second half: `apply()` is run again when the breakpoint itself flips.
56 // Without it the only `apply()` on the way back to portrait is the one the
57 // `resize` event drives, and at that moment `matchMedia` still answers false —
58 // measured, not assumed — so it takes the desktop branch and hides the rail
59 // with nothing following to undo it.
60 nomq: [{
61 file: 'js/daimond.js',
62 re: /\t\t\tif \(mobileMq\.addEventListener\) mobileMq\.addEventListener\('change', apply\);\n\t\t\telse if \(mobileMq\.addListener\) mobileMq\.addListener\(apply\);\n/,
63 with: '',
64 }],
65};
66
67if (BREAK && !BREAKS[BREAK]) {
68 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
69 process.exit(2);
70}
71
72/// The damaged source, or a hard stop. A break whose anchor is not there exactly
73/// once broke nothing, and the run below would prove nothing.
74function damaged(src, spec) {
75 const re = new RegExp(spec.re.source, spec.re.flags.includes('g') ? spec.re.flags : spec.re.flags + 'g');
76 const n = (src.match(re) || []).length;
77 if (n !== 1) {
78 console.error(`break '${BREAK}': the shape ${spec.re} matches ${n} time(s) in ${spec.file}.`);
79 process.exit(2);
80 }
81 return src.replace(spec.re, spec.with);
82}
83
84/// The damaged files, ONE BODY PER FILE: Playwright hands a request to the LAST
85/// route registered for its URL, so two routes for one file serve only the second.
86function damagedFiles() {
87 const byFile = new Map();
88 for (const spec of (BREAKS[BREAK] || [])) {
89 const src = byFile.has(spec.file) ? byFile.get(spec.file)
90 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
91 byFile.set(spec.file, damaged(src, spec));
92 }
93 return byFile;
94}
95
96const routeBreaks = async (pg) => {
97 if (!BREAK) return;
98 for (const [file, body] of damagedFiles()) {
99 await pg.route('**/' + file, r => r.fulfill({
100 status: 200, contentType: 'application/javascript', body,
101 }));
102 }
103};
104
105const ok = [], bad = [];
106const check = (name, pass, detail) => {
107 (pass ? ok : bad).push(name);
108 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
109};
110const sleep = ms => new Promise(r => setTimeout(r, ms));
111
112const s = await open({ signIn: true, connect: true, name: 'burgertester', route: routeBreaks });
113const { page } = s;
114
115const PORTRAIT = { width: 390, height: 844 }; // an iPhone 13, upright
116const LANDSCAPE = { width: 844, height: 390 }; // the same phone, turned
117
118/// Playwright's `setViewportSize` does not raise `resize` the way a device does,
119/// and the whole fault lives in what the app does when it is told.
120async function rotate(size) {
121 await page.setViewportSize(size);
122 await page.evaluate(() => window.dispatchEvent(new Event('resize')));
123 await sleep(700);
124}
125
126/// Press the hamburger and say whether the drawer really arrived. The class is
127/// not the answer: the fault under test toggles the class perfectly and draws
128/// nothing, so the rail's own box is what is read.
129async function drawerOpens() {
130 await page.evaluate(() => { document.body.classList.remove('drawer-open'); });
131 await sleep(350);
132 await page.evaluate(() => document.getElementById('drawer-btn').click());
133 await sleep(450);
134 return page.evaluate(() => {
135 const r = document.getElementById('panel-rail');
136 if (!r) return { on: false, why: 'no rail' };
137 const q = r.getBoundingClientRect();
138 return {
139 on: q.width > 100 && q.left > -8,
140 cls: document.body.classList.contains('drawer-open'),
141 display: getComputedStyle(r).display,
142 inline: r.style.display,
143 box: `${Math.round(q.left)},${Math.round(q.width)}`,
144 };
145 });
146}
147
148await rotate(PORTRAIT);
149// Headless does not reliably advance CSS transitions, so a measured mid-flight
150// value is meaningless; disable them so every assertion reads the settled state.
151await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' });
152await page.evaluate(() => { try { window.DaimondAdmin.closeModal(); } catch (e) {} });
153await sleep(300);
154
155// ── 1. Before anything is rotated ──────────────────────────
156let d = await drawerOpens();
157check('portrait: the hamburger opens the drawer', d.on, JSON.stringify(d));
158await page.evaluate(() => { document.body.classList.remove('drawer-open'); });
159await sleep(350);
160
161// ── 2. The landscape leg, which is where the inline style is written ──
162//
163// Asserted rather than assumed: if the desktop branch ever stops hiding the rail
164// in this band, the check below is passing for a reason that has gone.
165await rotate(LANDSCAPE);
166check('landscape: the desktop branch puts the rail away', await page.evaluate(() =>
167 document.getElementById('panel-rail').style.display === 'none'),
168 await page.evaluate(() => `inline='${document.getElementById('panel-rail').style.display}'`));
169
170// ── 3. And back, which is the bug ──────────────────────────
171await rotate(PORTRAIT);
172check('portrait again: no inline display is left on the rail', await page.evaluate(() =>
173 document.getElementById('panel-rail').style.display !== 'none'),
174 await page.evaluate(() => `inline='${document.getElementById('panel-rail').style.display}'`));
175
176d = await drawerOpens();
177check('portrait again: the hamburger opens the drawer', d.on, JSON.stringify(d));
178check('portrait again: the drawer is drawn, not merely flagged', d.on && d.display !== 'none',
179 JSON.stringify(d));
180await page.evaluate(() => { document.body.classList.remove('drawer-open'); });
181await sleep(400);
182
183// ── 4. Twice, because a fix that only survives one crossing is a fix that
184// fails on the second. ─────────────────────────────────
185await rotate(LANDSCAPE);
186await rotate(PORTRAIT);
187d = await drawerOpens();
188check('two rotations: the hamburger still opens the drawer', d.on, JSON.stringify(d));
189await page.evaluate(() => { document.body.classList.remove('drawer-open'); });
190await sleep(400);
191
192// ── 5. THE OBSERVATION THAT SEPARATES THIS FROM A BLOCKED THREAD ──
193//
194// If the main thread were the trouble, every control in the header would be dead
195// together. About is beside the hamburger and opens a dialog of its own, so it
196// answers the question in one press — and it must answer it in the broken world
197// too, which is why it is checked here and not only in the clean one.
198await page.evaluate(() => document.getElementById('about-btn').click());
199await sleep(500);
200check('the rest of the header is alive (About opens)', await page.evaluate(() => {
201 const dlg = document.querySelector('body > .modal.dlg');
202 return !!dlg && !!(dlg.offsetWidth || dlg.offsetHeight);
203}));
204await page.keyboard.press('Escape');
205await sleep(300);
206
207// ── 6. No desktop regression ───────────────────────────────
208//
209// The rail folds away on its own between 760 and 1280 and that is deliberate:
210// the fix must not un-fold it, only stop it outliving the phone.
211await rotate({ width: 1000, height: 800 });
212check('desktop 1000px: the rail is still folded away', await page.evaluate(() =>
213 getComputedStyle(document.getElementById('panel-rail')).display === 'none'),
214 await page.evaluate(() => getComputedStyle(document.getElementById('panel-rail')).display));
215await rotate({ width: 1400, height: 900 });
216check('desktop 1400px: the rail is back', await page.evaluate(() =>
217 getComputedStyle(document.getElementById('panel-rail')).display !== 'none'),
218 await page.evaluate(() => getComputedStyle(document.getElementById('panel-rail')).display));
219
220console.log(`\n${ok.length} ok, ${bad.length} failed${bad.length ? ': ' + bad.join(', ') : ''}`);
221await s.close();
222process.exit(bad.length ? 1 : 0);