oxedyne/daimond/dev/probe_burger.mjs
28.3 KiB, 1 run
created by r2519314175:37, 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 | // probe_burger.mjs — does the phone hamburger stop opening the drawer after a |
| 2 | // while, and does it track the SIZE of the transcript or the LIFE of the session? |
| 3 | // |
| 4 | // The report is "in iOS after modestly long chat use, the main hamburger icon |
| 5 | // left in the header for mobile view stops working". Two entirely different |
| 6 | // bugs answer to that sentence, so this drives both arms separately: |
| 7 | // |
| 8 | // --arm size a transcript is made big AT ONCE (no turns), so nothing has |
| 9 | // accumulated except nodes. |
| 10 | // --arm time many short turns, each leaving almost nothing on screen, so |
| 11 | // nothing has accumulated except events and elapsed life. |
| 12 | // --arm both (default) size first in one page, then time in a fresh one. |
| 13 | // |
| 14 | // THE PROBE IS PROVED BEFORE IT IS BELIEVED. `--break <name>` makes the |
| 15 | // hamburger dead by one named mechanism and the run must report it dead AND name |
| 16 | // that mechanism; a probe that can only see a scrim will report "not |
| 17 | // reproduced" in a world where the listener was lost. |
| 18 | // |
| 19 | // node dev/probe_burger.mjs --break h1-cover # an overlay over the header |
| 20 | // node dev/probe_burger.mjs --break h2-rebind # the button node replaced |
| 21 | // node dev/probe_burger.mjs --break h3-block # the main thread blocked |
| 22 | // node dev/probe_burger.mjs --break h4-latch # the open/closed state desynced |
| 23 | // node dev/probe_burger.mjs --arm both # and then, for real |
| 24 | // |
| 25 | // Engine: WebKit by default, because an iPhone runs WebKit. `--engine chromium` |
| 26 | // is for when this host's WebKit will not launch; it is a different engine and |
| 27 | // the report must say so. |
| 28 | import os from 'node:os'; |
| 29 | import path from 'node:path'; |
| 30 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 31 | import fs from 'node:fs'; |
| 32 | import { signInAs, connectMock, APP, scratch, MOCK, MODEL } from './harness.mjs'; |
| 33 | |
| 34 | const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www'); |
| 35 | |
| 36 | process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1'; |
| 37 | |
| 38 | const arg = (name, dflt) => { |
| 39 | const i = process.argv.indexOf('--' + name); |
| 40 | return i > 0 ? String(process.argv[i + 1] || '') : dflt; |
| 41 | }; |
| 42 | const ARM = arg('arm', 'both'); |
| 43 | const BREAK = arg('break', ''); |
| 44 | const ENGINE = arg('engine', 'webkit'); |
| 45 | const TURNS = Number(arg('turns', '24')); |
| 46 | const GROW = Number(arg('grow', '4000')); // synthetic messages for the size arm |
| 47 | const HEADED = process.argv.includes('--headed'); |
| 48 | |
| 49 | const BREAKS = { |
| 50 | 'h1-cover': 'an invisible fixed overlay lies over the top bar', |
| 51 | 'h2-rebind': 'the button node is replaced, so its listener is on a node that has gone', |
| 52 | 'h3-block': 'the main thread is blocked, so no event is processed', |
| 53 | 'h4-latch': 'the drawer believes it is already open, so the toggle closes nothing', |
| 54 | // The last two are not simulations: each serves the real file with the real |
| 55 | // fix taken out, which is what makes them proof rather than illustration. |
| 56 | 'noelse': "apply()'s phone branch does not put the rail's display back", |
| 57 | 'nomq': 'apply() is not run again when the breakpoint itself flips', |
| 58 | }; |
| 59 | |
| 60 | /// The two halves of the fix, each removable, so the probe can be pointed at the |
| 61 | /// world before it. Served through `page.route` in place of the real file. |
| 62 | const SOURCE_BREAKS = { |
| 63 | noelse: { |
| 64 | file: 'js/daimond.js', |
| 65 | re: /\t\t\t\} else \{\n\t\t\t\t\/\/ AND THE RAIL IS PUT BACK[\s\S]*?\n\t\t\t\}/, |
| 66 | with: '\t\t\t}', |
| 67 | }, |
| 68 | nomq: { |
| 69 | file: 'js/daimond.js', |
| 70 | re: /\t\t\tif \(mobileMq\.addEventListener\) mobileMq\.addEventListener\('change', apply\);\n\t\t\telse if \(mobileMq\.addListener\) mobileMq\.addListener\(apply\);\n/, |
| 71 | with: '', |
| 72 | }, |
| 73 | }; |
| 74 | |
| 75 | /// The file with that half removed, or a hard stop: an anchor that is not there |
| 76 | /// exactly once removed nothing, and the run would prove nothing. |
| 77 | function damagedSource(name) { |
| 78 | const spec = SOURCE_BREAKS[name]; |
| 79 | const src = fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 80 | const n = (src.match(new RegExp(spec.re.source, 'g')) || []).length; |
| 81 | if (n !== 1) { |
| 82 | console.error(`break '${name}': the shape matches ${n} time(s) in ${spec.file}.`); |
| 83 | process.exit(2); |
| 84 | } |
| 85 | return { file: spec.file, body: src.replace(spec.re, spec.with) }; |
| 86 | } |
| 87 | if (BREAK && !BREAKS[BREAK]) { |
| 88 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 89 | process.exit(2); |
| 90 | } |
| 91 | |
| 92 | const PW = process.env.DAIMOND_PW || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 93 | const pw = await import(pathToFileURL(PW).href); |
| 94 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 95 | |
| 96 | const iph = pw.devices['iPhone 13']; |
| 97 | |
| 98 | async function launch(tag) { |
| 99 | const dir = scratch(`burger-${ENGINE}-${tag}-${process.pid}`); |
| 100 | const common = { |
| 101 | viewport: iph.viewport, |
| 102 | deviceScaleFactor: iph.deviceScaleFactor, |
| 103 | userAgent: iph.userAgent, |
| 104 | hasTouch: true, |
| 105 | timeout: 60000, |
| 106 | headless: !HEADED, |
| 107 | }; |
| 108 | if (ENGINE === 'chromium') { |
| 109 | return pw.chromium.launchPersistentContext(dir, { ...common, isMobile: true }); |
| 110 | } |
| 111 | // WebKit rejects `isMobile`. |
| 112 | return pw.webkit.launchPersistentContext(dir, common); |
| 113 | } |
| 114 | |
| 115 | // ── The verdict: is the hamburger alive? ───────────────────────────────── |
| 116 | // |
| 117 | // Not "did a click succeed" alone. Every hypothesis leaves a different |
| 118 | // fingerprint, so all of them are read on every pass and the failing one names |
| 119 | // itself. |
| 120 | const READ = `(() => { |
| 121 | const btn = document.getElementById('drawer-btn'); |
| 122 | const rail = document.getElementById('panel-rail'); |
| 123 | const out = document.getElementById('chat-output'); |
| 124 | const r = btn ? btn.getBoundingClientRect() : null; |
| 125 | const cx = r ? Math.round(r.left + r.width / 2) : -1; |
| 126 | const cy = r ? Math.round(r.top + r.height / 2) : -1; |
| 127 | const at = (r && r.width) ? document.elementFromPoint(cx, cy) : null; |
| 128 | const desc = e => !e ? '(nothing)' |
| 129 | : e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') |
| 130 | + (e.className && typeof e.className === 'string' && e.className.trim() |
| 131 | ? '.' + e.className.trim().split(/\\s+/).join('.') : ''); |
| 132 | const rr = rail ? rail.getBoundingClientRect() : null; |
| 133 | return { |
| 134 | nodes: out ? out.querySelectorAll('*').length : 0, |
| 135 | msgs: out ? out.children.length : 0, |
| 136 | btn: !!btn, |
| 137 | w: r ? Math.round(r.width) : 0, |
| 138 | h: r ? Math.round(r.height) : 0, |
| 139 | cx, cy, |
| 140 | // H1: what is really at the point a thumb lands on. |
| 141 | at: desc(at), |
| 142 | covered: !!(btn && at && at !== btn && !btn.contains(at)), |
| 143 | // H2: is this the node the listener was put on? |
| 144 | sameNode: !!(btn && window.__pbBtn === btn), |
| 145 | mark: !!(btn && btn.dataset && btn.dataset.pbMark === '1'), |
| 146 | // H4: what the shell believes. |
| 147 | open: document.body.classList.contains('drawer-open'), |
| 148 | railLeft: rr ? Math.round(rr.left) : null, |
| 149 | railW: rr ? Math.round(rr.width) : null, |
| 150 | // The drawer is on screen when the rail's left edge has come back to 0. |
| 151 | drawer: !!(rr && rr.width > 100 && rr.left > -8), |
| 152 | // H4's own fingerprint: the class can toggle perfectly on a rail that is |
| 153 | // not drawn at all. |
| 154 | railDisp: rail ? getComputedStyle(rail).display : '', |
| 155 | railInline: rail ? rail.style.display : '', |
| 156 | pointer: btn ? getComputedStyle(btn).pointerEvents : '', |
| 157 | disp: btn ? getComputedStyle(btn).display : '', |
| 158 | heap: (performance.memory && performance.memory.usedJSHeapSize) || 0, |
| 159 | }; |
| 160 | })()`; |
| 161 | |
| 162 | /// An `evaluate` that gives up rather than waiting out a blocked thread. |
| 163 | /// |
| 164 | /// Waiting is what made the first version of this probe blind to H3: it asked |
| 165 | /// the page a question, the page answered ten seconds later, and by then the |
| 166 | /// block was over and the press worked. A refusal to wait is the observation. |
| 167 | async function ev(page, fn, arg, ms = 1200) { |
| 168 | let timer; |
| 169 | const bell = new Promise(r => { timer = setTimeout(() => r('__pbTimeout'), ms); }); |
| 170 | const out = await Promise.race([page.evaluate(fn, arg).catch(() => '__pbTimeout'), bell]); |
| 171 | clearTimeout(timer); |
| 172 | return out; |
| 173 | } |
| 174 | |
| 175 | /// The longest the page went unattended since this was last called. |
| 176 | async function gap(page) { |
| 177 | const g = await ev(page, () => { const g = window.__pbGap || 0; window.__pbGap = 0; return g; }); |
| 178 | return g === '__pbTimeout' ? Infinity : g; |
| 179 | } |
| 180 | |
| 181 | /// Press the hamburger the way a thumb does: at its coordinates, through the |
| 182 | /// hit test, with no `force` to paper over anything lying on top of it. |
| 183 | async function pressBurger(page, s) { |
| 184 | // NOTHING is reset here. Clearing `drawer-open` before the press would |
| 185 | // destroy the very desynchronisation H4 is about -- the probe would heal the |
| 186 | // fault it was built to find, and report the button healthy. |
| 187 | if (!s.cx || s.cx < 0) return { pressed: false, after: s }; |
| 188 | // A real tap, since a phone has no mouse. Both are dispatched through the |
| 189 | // engine's hit test, so an overlay eats them exactly as it would a thumb. |
| 190 | await page.touchscreen.tap(s.cx, s.cy).catch(() => {}); |
| 191 | await sleep(500); |
| 192 | let after = await ev(page, new Function('return ' + READ)); |
| 193 | // The tap WAS received if it flipped the class -- and a second press would |
| 194 | // flip it back, hiding the very evidence that the press arrived and the |
| 195 | // drawer did not. One press is the whole experiment in that case. |
| 196 | if (after !== '__pbTimeout' && after.open && !after.drawer) return { pressed: true, after }; |
| 197 | if (after === '__pbTimeout' || !after.drawer) { |
| 198 | // WebKit does not always synthesise a click from a bare tap; a mouse |
| 199 | // click at the same point is the same hit test and is not a bypass. |
| 200 | await page.mouse.click(s.cx, s.cy).catch(() => {}); |
| 201 | await sleep(500); |
| 202 | after = await ev(page, new Function('return ' + READ)); |
| 203 | } |
| 204 | return { pressed: true, after: after === '__pbTimeout' ? null : after }; |
| 205 | } |
| 206 | |
| 207 | /// Is some OTHER control in the header still alive? The single observation that |
| 208 | /// separates "the whole thread is dead" from "this one button is dead". |
| 209 | async function neighbourAlive(page) { |
| 210 | // The drawer slides for 280ms and covers 344 of a 390px screen while it does, |
| 211 | // About included -- so a click sent the instant the drawer is asked to close |
| 212 | // lands on the drawer and reports a healthy button dead. |
| 213 | await ev(page, () => { document.body.classList.remove('drawer-open'); }); |
| 214 | await sleep(600); |
| 215 | const r = await page.evaluate(() => { |
| 216 | const b = document.getElementById('about-btn'); |
| 217 | if (!b) return null; |
| 218 | const q = b.getBoundingClientRect(); |
| 219 | return { cx: Math.round(q.left + q.width / 2), cy: Math.round(q.top + q.height / 2) }; |
| 220 | }); |
| 221 | if (!r) return null; |
| 222 | await page.mouse.click(r.cx, r.cy).catch(() => {}); |
| 223 | await sleep(400); |
| 224 | // About is built at the moment it is asked for, as `div.modal.dlg` appended |
| 225 | // to the body -- there is no markup to look for until the button works. |
| 226 | const open = await page.evaluate(() => { |
| 227 | const d = document.querySelector('body > .modal.dlg'); |
| 228 | const vis = e => !!e && !!(e.offsetWidth || e.offsetHeight || e.getClientRects().length); |
| 229 | return vis(d); |
| 230 | }); |
| 231 | // Put it back. |
| 232 | await page.keyboard.press('Escape').catch(() => {}); |
| 233 | await sleep(250); |
| 234 | return open; |
| 235 | } |
| 236 | |
| 237 | /// Which of the five worlds this pass is in. |
| 238 | /// |
| 239 | /// The order is not cosmetic. A blocked thread hides every other symptom (the |
| 240 | /// state cannot even be read), and a stuck `drawer-open` puts the scrim over the |
| 241 | /// button — which reads as H1 unless H4 is asked about first. |
| 242 | function verdict(before, res, gapMs) { |
| 243 | if (!before) return { dead: true, why: `H3 — the page never answered; it went ${gapMs}ms unattended` }; |
| 244 | const a = res.after || before; |
| 245 | if (a.drawer) return { dead: false, why: 'the drawer opened' }; |
| 246 | if (gapMs > 1500) return { dead: true, why: `H3 — the main thread went ${gapMs}ms unattended, so the press was never processed` }; |
| 247 | if (before.open) return { dead: true, why: `H4 — the shell already believed the drawer was open while it was off screen (press landed on ${before.at})` }; |
| 248 | if (before.covered) return { dead: true, why: `H1 — the press lands on ${before.at}, not on the button` }; |
| 249 | if (!before.sameNode || !before.mark) return { dead: true, why: 'H2 — the button in the document is not the node the listener was bound to' }; |
| 250 | if (a.open && a.railDisp === 'none') return { dead: true, why: `H4 — the class toggled and the rail is display:${a.railDisp} (inline '${a.railInline}'), so nothing is drawn` }; |
| 251 | if (a.open && !a.drawer) return { dead: true, why: 'H4 — the class toggled but the drawer did not come on screen' }; |
| 252 | if (!before.btn || !before.w) return { dead: true, why: `the button is not on screen (display:${before.disp}, ${before.w}x${before.h})` }; |
| 253 | return { dead: true, why: 'the press reached the button and nothing happened (listener gone, or the handler returned early)' }; |
| 254 | } |
| 255 | |
| 256 | async function pass(page, label) { |
| 257 | let s = await ev(page, new Function('return ' + READ)); |
| 258 | if (s === '__pbTimeout') s = null; |
| 259 | const res = await pressBurger(page, s || { cx: 20, cy: 30 }); |
| 260 | const gapMs = await gap(page); |
| 261 | const v = verdict(s, res, gapMs); |
| 262 | // Leave it closed for the next pass. |
| 263 | await ev(page, () => { document.body.classList.remove('drawer-open'); }); |
| 264 | const shown = s || res.after || {}; |
| 265 | const line = ` ${label.padEnd(22)} nodes=${String(shown.nodes ?? '?').padStart(6)} msgs=${String(shown.msgs ?? '?').padStart(4)}` |
| 266 | + ` gap=${String(gapMs).padStart(5)}ms at=${String(shown.at || '(unread)').slice(0, 40).padEnd(40)}` |
| 267 | + ` -> ${v.dead ? 'DEAD ' : 'alive '} ${v.dead ? v.why : ''}`; |
| 268 | console.log(line); |
| 269 | return { label, s: shown, v, gapMs }; |
| 270 | } |
| 271 | |
| 272 | async function applyBreak(page) { |
| 273 | if (!BREAK || SOURCE_BREAKS[BREAK]) return; |
| 274 | await page.evaluate((which) => { |
| 275 | if (which === 'h1-cover') { |
| 276 | const d = document.createElement('div'); |
| 277 | d.id = 'pb-cover'; |
| 278 | d.style.cssText = 'position:fixed;inset:0;z-index:99999;background:transparent'; |
| 279 | document.body.appendChild(d); |
| 280 | } else if (which === 'h2-rebind') { |
| 281 | const b = document.getElementById('drawer-btn'); |
| 282 | if (b) b.replaceWith(b.cloneNode(true)); |
| 283 | } else if (which === 'h3-block') { |
| 284 | // Ten seconds of arithmetic, started now, so every event queued |
| 285 | // behind it waits — which is what a synchronous re-render does. |
| 286 | setTimeout(() => { const end = Date.now() + 30000; while (Date.now() < end) { /* burn */ } }, 0); |
| 287 | } else if (which === 'h4-latch') { |
| 288 | document.body.classList.add('drawer-open'); |
| 289 | const rail = document.getElementById('panel-rail'); |
| 290 | if (rail) rail.style.display = 'none'; // what `hide('rail')` does |
| 291 | } |
| 292 | }, BREAK); |
| 293 | await sleep(50); |
| 294 | } |
| 295 | |
| 296 | async function setUp(tag) { |
| 297 | const ctx = await launch(tag); |
| 298 | const page = ctx.pages()[0] || await ctx.newPage(); |
| 299 | await ctx.addInitScript(COUNT_LISTENERS); |
| 300 | if (SOURCE_BREAKS[BREAK]) { |
| 301 | const { file, body } = damagedSource(BREAK); |
| 302 | await page.route('**/' + file, r => r.fulfill({ |
| 303 | status: 200, contentType: 'application/javascript', body, |
| 304 | })); |
| 305 | } |
| 306 | const errs = []; |
| 307 | page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); }); |
| 308 | page.on('pageerror', e => errs.push('pageerror: ' + e.message)); |
| 309 | const s = { browser: ctx, page, errs, logs: [], name: 'burgertester', close: () => ctx.close() }; |
| 310 | await page.goto(APP, { waitUntil: 'domcontentloaded' }); |
| 311 | await sleep(600); |
| 312 | await signInAs(s, 'burgertester'); |
| 313 | await connectMock(s, { baseUrl: MOCK, model: MODEL }); |
| 314 | await page.evaluate(() => { try { window.DaimondAdmin.closeModal(); } catch (e) {} }); |
| 315 | await page.evaluate(() => { document.body.classList.remove('drawer-open'); }); |
| 316 | await sleep(400); |
| 317 | // Mark the button so a later pass can tell whether this is still it. |
| 318 | // |
| 319 | // And start a heartbeat. A blocked main thread cannot be measured from |
| 320 | // outside -- every `evaluate` simply waits for it, and by the time the answer |
| 321 | // comes back the thread is free again and the press succeeds. The page has to |
| 322 | // keep its own record of the longest it went unattended, which is read |
| 323 | // afterwards. |
| 324 | await page.evaluate(() => { |
| 325 | const b = document.getElementById('drawer-btn'); |
| 326 | if (b) { b.dataset.pbMark = '1'; window.__pbBtn = b; } |
| 327 | window.__pbGap = 0; |
| 328 | let last = Date.now(); |
| 329 | setInterval(() => { |
| 330 | const now = Date.now(); |
| 331 | if (now - last > window.__pbGap) window.__pbGap = now - last; |
| 332 | last = now; |
| 333 | }, 40); |
| 334 | }); |
| 335 | return { ctx, page, s }; |
| 336 | } |
| 337 | |
| 338 | /// Start a chat and say something, without the harness's newChat, which is |
| 339 | /// written for a desktop rail. |
| 340 | async function startChat(page) { |
| 341 | await page.evaluate(() => { |
| 342 | document.body.classList.remove('drawer-open'); |
| 343 | const b = document.getElementById('new-session-btn'); |
| 344 | if (b) b.click(); |
| 345 | }); |
| 346 | await sleep(600); |
| 347 | await page.evaluate(() => { |
| 348 | const t = document.querySelector('.tile-start'); |
| 349 | if (t) t.click(); |
| 350 | }); |
| 351 | await page.waitForSelector('#chat-input', { state: 'visible', timeout: 15000 }); |
| 352 | await sleep(300); |
| 353 | } |
| 354 | |
| 355 | async function turn(page, text, timeout = 25000) { |
| 356 | await page.evaluate(() => { document.body.classList.remove('drawer-open'); }); |
| 357 | await page.fill('#chat-input', text); |
| 358 | await page.click('#chat-send', { force: true }); |
| 359 | await sleep(250); |
| 360 | const t0 = Date.now(); |
| 361 | while (Date.now() - t0 < timeout) { |
| 362 | const busy = await page.evaluate(() => { |
| 363 | const b = document.getElementById('chat-send'); |
| 364 | if (!b) return false; |
| 365 | const t = (b.getAttribute('title') || '') + (b.className || ''); |
| 366 | return /stop/i.test(t) || b.disabled; |
| 367 | }).catch(() => false); |
| 368 | if (!busy) break; |
| 369 | await sleep(200); |
| 370 | } |
| 371 | await sleep(200); |
| 372 | } |
| 373 | |
| 374 | /// Everything POSITIONED that overlaps the hamburger's centre, whatever it looks |
| 375 | /// like. An overlay that is transparent, or one pixel of a menu that was never |
| 376 | /// removed, is invisible to a screenshot and obvious here. |
| 377 | const CENSUS = `(() => { |
| 378 | const b = document.getElementById('drawer-btn'); |
| 379 | if (!b) return ['(no button)']; |
| 380 | const r = b.getBoundingClientRect(); |
| 381 | const cx = r.left + r.width / 2, cy = r.top + r.height / 2; |
| 382 | const desc = e => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') |
| 383 | + (e.className && typeof e.className === 'string' && e.className.trim() |
| 384 | ? '.' + e.className.trim().split(/\\s+/).join('.') : ''); |
| 385 | const out = []; |
| 386 | document.querySelectorAll('*').forEach(el => { |
| 387 | if (el === b || b.contains(el) || el.contains(b)) return; |
| 388 | const cs = getComputedStyle(el); |
| 389 | if (cs.position === 'static') return; |
| 390 | const q = el.getBoundingClientRect(); |
| 391 | if (q.width < 1 || q.height < 1) return; |
| 392 | if (cx < q.left || cx > q.right || cy < q.top || cy > q.bottom) return; |
| 393 | out.push(desc(el) + ' z=' + cs.zIndex + ' pe=' + cs.pointerEvents |
| 394 | + ' op=' + cs.opacity + ' vis=' + cs.visibility); |
| 395 | }); |
| 396 | return out; |
| 397 | })()`; |
| 398 | |
| 399 | /// Listeners, counted from before the first line of app code runs. |
| 400 | /// |
| 401 | /// A handler bound once per turn is invisible in every other reading: the DOM |
| 402 | /// does not grow, nothing covers anything, and the page answers instantly right |
| 403 | /// up to the moment it does not. |
| 404 | const COUNT_LISTENERS = () => { |
| 405 | window.__pbLis = {}; |
| 406 | const add = EventTarget.prototype.addEventListener; |
| 407 | const rem = EventTarget.prototype.removeEventListener; |
| 408 | const key = (t, type) => (t === window ? 'window' : t === document ? 'document' |
| 409 | : (t && t.nodeType === 1 ? (t.id ? '#' + t.id : t.tagName.toLowerCase()) : 'other')) + ':' + type; |
| 410 | EventTarget.prototype.addEventListener = function (type, fn, opt) { |
| 411 | try { const k = key(this, type); window.__pbLis[k] = (window.__pbLis[k] || 0) + 1; } catch (e) {} |
| 412 | return add.call(this, type, fn, opt); |
| 413 | }; |
| 414 | EventTarget.prototype.removeEventListener = function (type, fn, opt) { |
| 415 | try { const k = key(this, type); window.__pbLis[k] = (window.__pbLis[k] || 0) - 1; } catch (e) {} |
| 416 | return rem.call(this, type, fn, opt); |
| 417 | }; |
| 418 | }; |
| 419 | |
| 420 | /// The top ten listener keys, so a leak names itself. |
| 421 | async function listeners(page) { |
| 422 | const l = await ev(page, () => Object.entries(window.__pbLis || {}) |
| 423 | .sort((a, b) => b[1] - a[1]).slice(0, 10).map(e => e[0] + '=' + e[1])); |
| 424 | return l === '__pbTimeout' ? ['(unread)'] : l; |
| 425 | } |
| 426 | |
| 427 | const results = []; |
| 428 | |
| 429 | // ── SIZE: a big transcript, at once ────────────────────────────────────── |
| 430 | if (ARM === 'size' || ARM === 'both') { |
| 431 | console.log(`\n[size] ${GROW} synthetic messages injected at once, no turns\n`); |
| 432 | const { ctx, page } = await setUp('size'); |
| 433 | await startChat(page); |
| 434 | await applyBreak(page); |
| 435 | results.push(await pass(page, 'size: empty')); |
| 436 | for (const n of [250, 1000, 2500, GROW]) { |
| 437 | await page.evaluate((n) => { |
| 438 | const out = document.getElementById('chat-output'); |
| 439 | if (!out) return; |
| 440 | const have = out.querySelectorAll('.chat-msg-asst, .chat-msg-user').length; |
| 441 | for (let i = have; i < n; i++) { |
| 442 | const d = document.createElement('div'); |
| 443 | d.className = i % 2 ? 'chat-msg chat-msg-user' : 'chat-msg chat-msg-asst'; |
| 444 | d.setAttribute('data-turn', String(i >> 1)); |
| 445 | d.innerHTML = '<div class="msg-body"><p>' + 'filler '.repeat(24) + i + '</p></div>'; |
| 446 | out.appendChild(d); |
| 447 | } |
| 448 | out.scrollTop = out.scrollHeight; |
| 449 | }, n); |
| 450 | await sleep(300); |
| 451 | results.push(await pass(page, `size: ${n} msgs`)); |
| 452 | } |
| 453 | await ctx.close(); |
| 454 | } |
| 455 | |
| 456 | // ── TIME: many short turns ─────────────────────────────────────────────── |
| 457 | if (ARM === 'time' || ARM === 'both') { |
| 458 | console.log(`\n[time] ${TURNS} short turns, each leaving one line on screen\n`); |
| 459 | const { ctx, page } = await setUp('time'); |
| 460 | await startChat(page); |
| 461 | await applyBreak(page); |
| 462 | results.push(await pass(page, 'time: turn 0')); |
| 463 | for (let i = 1; i <= TURNS; i++) { |
| 464 | await turn(page, `@text ok ${i}`); |
| 465 | if (i % 2 === 0 || i === TURNS) results.push(await pass(page, `time: turn ${i}`)); |
| 466 | } |
| 467 | const alive = await neighbourAlive(page); |
| 468 | console.log(` neighbour (#about-btn) alive: ${alive}`); |
| 469 | await ctx.close(); |
| 470 | } |
| 471 | |
| 472 | |
| 473 | // ── SOAK: a session lived rather than driven ───────────────────────────── |
| 474 | // |
| 475 | // The two arms above each isolate one variable, which is what they are for. A |
| 476 | // phone session is neither: long answers, a sheet raised and dropped, the |
| 477 | // composer focused, the chat scrolled, and the app put in the background every |
| 478 | // few turns because that is what a phone does. The hamburger is NOT pressed |
| 479 | // until the end -- pressing it every other turn is how the first version of this |
| 480 | // probe healed anything that might have gone wrong. |
| 481 | if (ARM === 'soak') { |
| 482 | console.log(`\n[soak] ${TURNS} turns of a lived session; the burger is left alone until the end\n`); |
| 483 | const { ctx, page } = await setUp('soak'); |
| 484 | await startChat(page); |
| 485 | await applyBreak(page); |
| 486 | results.push(await pass(page, 'soak: turn 0')); |
| 487 | console.log(' listeners at start: ' + (await listeners(page)).join(' ')); |
| 488 | for (let i = 1; i <= TURNS; i++) { |
| 489 | const long = 'word'.repeat(1) + ' lorem ipsum dolor sit amet '.repeat(60); |
| 490 | await turn(page, i % 3 === 0 ? `@text ${long}` : `@text short answer ${i}`); |
| 491 | // The composer, focused and blurred: on a phone that is the keyboard |
| 492 | // coming up and going down, and with it a visualViewport resize. |
| 493 | await ev(page, () => { |
| 494 | const c = document.getElementById('chat-input'); |
| 495 | if (c) { c.focus(); c.blur(); } |
| 496 | if (window.visualViewport) window.visualViewport.dispatchEvent(new Event('resize')); |
| 497 | }); |
| 498 | // Scrolling back and forward through the transcript. |
| 499 | await ev(page, () => { |
| 500 | const o = document.getElementById('chat-output'); |
| 501 | if (o) { o.scrollTop = 0; o.dispatchEvent(new Event('scroll')); o.scrollTop = o.scrollHeight; } |
| 502 | }); |
| 503 | if (i % 4 === 0) { |
| 504 | // A sheet up and down: the one surface a phone has that a desktop does not. |
| 505 | await ev(page, () => { try { window.DaimondPanels.show('web'); } catch (e) {} }); |
| 506 | await sleep(400); |
| 507 | await ev(page, () => { try { window.DaimondSheet.close(); } catch (e) {} }); |
| 508 | await sleep(300); |
| 509 | } |
| 510 | if (i % 5 === 0) { |
| 511 | // The app switch. Playwright cannot really background a tab, so the |
| 512 | // events a background raises are dispatched instead -- which is what |
| 513 | // the app's own handlers listen for. |
| 514 | await ev(page, () => { |
| 515 | Object.defineProperty(document, 'visibilityState', { value: 'hidden', configurable: true }); |
| 516 | document.dispatchEvent(new Event('visibilitychange')); |
| 517 | window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); |
| 518 | }); |
| 519 | await sleep(400); |
| 520 | await ev(page, () => { |
| 521 | Object.defineProperty(document, 'visibilityState', { value: 'visible', configurable: true }); |
| 522 | document.dispatchEvent(new Event('visibilitychange')); |
| 523 | window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); |
| 524 | window.dispatchEvent(new Event('resize')); |
| 525 | }); |
| 526 | await sleep(400); |
| 527 | } |
| 528 | if (i % 6 === 0) { |
| 529 | const c = await ev(page, new Function('return ' + CENSUS)); |
| 530 | if (Array.isArray(c) && c.length) console.log(` turn ${i} over the burger: ${c.join(' | ')}`); |
| 531 | } |
| 532 | } |
| 533 | console.log(' listeners at end: ' + (await listeners(page)).join(' ')); |
| 534 | const c = await ev(page, new Function('return ' + CENSUS)); |
| 535 | console.log(' over the burger: ' + (Array.isArray(c) && c.length ? c.join(' | ') : '(nothing)')); |
| 536 | results.push(await pass(page, `soak: turn ${TURNS}`)); |
| 537 | const alive = await neighbourAlive(page); |
| 538 | console.log(` neighbour (#about-btn) alive: ${alive}`); |
| 539 | await ctx.close(); |
| 540 | } |
| 541 | |
| 542 | |
| 543 | // ── ROTATE: the phone turned on its side and back ──────────────────────── |
| 544 | // |
| 545 | // An iPhone in landscape is 844 CSS pixels wide, which is above the 760 the |
| 546 | // phone shell is bounded by and below the 1280 at which the desktop layout keeps |
| 547 | // its rail. So a rotation takes the app out of phone mode and into the band |
| 548 | // where the rail folds away on its own. |
| 549 | if (ARM === 'rotate') { |
| 550 | console.log('\n[rotate] portrait -> landscape -> portrait, with turns in between\n'); |
| 551 | const { ctx, page } = await setUp('rotate'); |
| 552 | await startChat(page); |
| 553 | await applyBreak(page); |
| 554 | results.push(await pass(page, 'rotate: before')); |
| 555 | for (let i = 1; i <= Math.max(2, Math.min(TURNS, 4)); i++) await turn(page, `@text ok ${i}`); |
| 556 | results.push(await pass(page, 'rotate: after turns')); |
| 557 | |
| 558 | await page.setViewportSize({ width: 844, height: 390 }); |
| 559 | await sleep(700); |
| 560 | const land = await ev(page, () => ({ |
| 561 | w: window.innerWidth, |
| 562 | mobile: window.matchMedia('(max-width: 760px)').matches, |
| 563 | railDisp: (document.getElementById('panel-rail') || {}).style |
| 564 | ? document.getElementById('panel-rail').style.display : '?', |
| 565 | })); |
| 566 | console.log(` landscape: innerWidth=${land.w} phoneMode=${land.mobile} rail inline display='${land.railDisp}'`); |
| 567 | |
| 568 | // What the app is told, and when. The media query is known to lag the |
| 569 | // resize event, and which of the two `apply()` believes decides the branch. |
| 570 | await ev(page, () => { |
| 571 | window.__pbTrace = []; |
| 572 | const mq = window.matchMedia('(max-width: 760px)'); |
| 573 | window.addEventListener('resize', () => window.__pbTrace.push( |
| 574 | 'resize w=' + window.innerWidth + ' mq=' + mq.matches |
| 575 | + ' railDisp=' + document.getElementById('panel-rail').style.display), true); |
| 576 | mq.addEventListener('change', () => window.__pbTrace.push( |
| 577 | 'mqchange w=' + window.innerWidth + ' mq=' + mq.matches |
| 578 | + ' railDisp=' + document.getElementById('panel-rail').style.display)); |
| 579 | }); |
| 580 | await page.setViewportSize({ width: 390, height: 844 }); |
| 581 | await sleep(900); |
| 582 | const trace = await ev(page, () => window.__pbTrace || []); |
| 583 | console.log(' trace: ' + JSON.stringify(trace)); |
| 584 | const port = await ev(page, () => ({ |
| 585 | w: window.innerWidth, |
| 586 | mobile: window.matchMedia('(max-width: 760px)').matches, |
| 587 | railDisp: document.getElementById('panel-rail').style.display, |
| 588 | computed: getComputedStyle(document.getElementById('panel-rail')).display, |
| 589 | })); |
| 590 | console.log(` back to portrait: innerWidth=${port.w} phoneMode=${port.mobile} rail inline display='${port.railDisp}' computed=${port.computed}`); |
| 591 | results.push(await pass(page, 'rotate: after')); |
| 592 | const alive = await neighbourAlive(page); |
| 593 | console.log(` neighbour (#about-btn) alive: ${alive}`); |
| 594 | await ctx.close(); |
| 595 | } |
| 596 | |
| 597 | // ── The answer ─────────────────────────────────────────────────────────── |
| 598 | const dead = results.filter(r => r.v.dead); |
| 599 | console.log(''); |
| 600 | if (BREAK) { |
| 601 | // A source break is a fault that only bites after a rotation, so only the |
| 602 | // rotate arm can be blind to it. Reporting "the probe is blind" from an arm |
| 603 | // that was never pointed at the fault would be a false accusation against a |
| 604 | // working instrument -- and, worse, a habit of ignoring the line. |
| 605 | if (SOURCE_BREAKS[BREAK] && ARM !== 'rotate') { |
| 606 | console.log(`BREAK ${BREAK} bites only after a rotation; run it with --arm rotate.`); |
| 607 | process.exit(2); |
| 608 | } |
| 609 | const seen = dead.length > 0; |
| 610 | console.log(`BREAK ${BREAK} (${BREAKS[BREAK]}): the probe reported it ${seen ? 'DEAD' : 'ALIVE — THE PROBE IS BLIND TO IT'}`); |
| 611 | if (seen) console.log(` and named it: ${dead[0].v.why}`); |
| 612 | process.exit(seen ? 0 : 1); |
| 613 | } |
| 614 | if (!dead.length) { |
| 615 | console.log(`NOT REPRODUCED on ${ENGINE}: the hamburger opened the drawer at every pass.`); |
| 616 | } else { |
| 617 | console.log(`REPRODUCED on ${ENGINE}: first dead at "${dead[0].label}" — ${dead[0].v.why}`); |
| 618 | for (const d of dead) console.log(` dead: ${d.label} — ${d.v.why}`); |
| 619 | } |
| 620 | process.exit(0); |