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. |
| 30 | import fs from 'node:fs'; |
| 31 | import path from 'node:path'; |
| 32 | import { fileURLToPath } from 'node:url'; |
| 33 | import { open } from './harness.mjs'; |
| 34 | |
| 35 | const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www'); |
| 36 | |
| 37 | const 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. |
| 44 | const 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 | |
| 67 | if (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. |
| 74 | function 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. |
| 86 | function 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 | |
| 96 | const 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 | |
| 105 | const ok = [], bad = []; |
| 106 | const check = (name, pass, detail) => { |
| 107 | (pass ? ok : bad).push(name); |
| 108 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 109 | }; |
| 110 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 111 | |
| 112 | const s = await open({ signIn: true, connect: true, name: 'burgertester', route: routeBreaks }); |
| 113 | const { page } = s; |
| 114 | |
| 115 | const PORTRAIT = { width: 390, height: 844 }; // an iPhone 13, upright |
| 116 | const 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. |
| 120 | async 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. |
| 129 | async 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 | |
| 148 | await 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. |
| 151 | await page.addStyleTag({ content: '*,*::before,*::after{transition:none!important;animation:none!important}' }); |
| 152 | await page.evaluate(() => { try { window.DaimondAdmin.closeModal(); } catch (e) {} }); |
| 153 | await sleep(300); |
| 154 | |
| 155 | // ── 1. Before anything is rotated ────────────────────────── |
| 156 | let d = await drawerOpens(); |
| 157 | check('portrait: the hamburger opens the drawer', d.on, JSON.stringify(d)); |
| 158 | await page.evaluate(() => { document.body.classList.remove('drawer-open'); }); |
| 159 | await 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. |
| 165 | await rotate(LANDSCAPE); |
| 166 | check('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 ────────────────────────── |
| 171 | await rotate(PORTRAIT); |
| 172 | check('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 | |
| 176 | d = await drawerOpens(); |
| 177 | check('portrait again: the hamburger opens the drawer', d.on, JSON.stringify(d)); |
| 178 | check('portrait again: the drawer is drawn, not merely flagged', d.on && d.display !== 'none', |
| 179 | JSON.stringify(d)); |
| 180 | await page.evaluate(() => { document.body.classList.remove('drawer-open'); }); |
| 181 | await sleep(400); |
| 182 | |
| 183 | // ── 4. Twice, because a fix that only survives one crossing is a fix that |
| 184 | // fails on the second. ───────────────────────────────── |
| 185 | await rotate(LANDSCAPE); |
| 186 | await rotate(PORTRAIT); |
| 187 | d = await drawerOpens(); |
| 188 | check('two rotations: the hamburger still opens the drawer', d.on, JSON.stringify(d)); |
| 189 | await page.evaluate(() => { document.body.classList.remove('drawer-open'); }); |
| 190 | await 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. |
| 198 | await page.evaluate(() => document.getElementById('about-btn').click()); |
| 199 | await sleep(500); |
| 200 | check('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 | })); |
| 204 | await page.keyboard.press('Escape'); |
| 205 | await 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. |
| 211 | await rotate({ width: 1000, height: 800 }); |
| 212 | check('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)); |
| 215 | await rotate({ width: 1400, height: 900 }); |
| 216 | check('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 | |
| 220 | console.log(`\n${ok.length} ok, ${bad.length} failed${bad.length ? ': ' + bad.join(', ') : ''}`); |
| 221 | await s.close(); |
| 222 | process.exit(bad.length ? 1 : 0); |