Oregami
Repositories/oxedyne/daimond

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.
28import os from 'node:os';
29import path from 'node:path';
30import { fileURLToPath, pathToFileURL } from 'node:url';
31import fs from 'node:fs';
32import { signInAs, connectMock, APP, scratch, MOCK, MODEL } from './harness.mjs';
33
34const WWW = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'www');
35
36process.env.PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS = '1';
37
38const arg = (name, dflt) => {
39 const i = process.argv.indexOf('--' + name);
40 return i > 0 ? String(process.argv[i + 1] || '') : dflt;
41};
42const ARM = arg('arm', 'both');
43const BREAK = arg('break', '');
44const ENGINE = arg('engine', 'webkit');
45const TURNS = Number(arg('turns', '24'));
46const GROW = Number(arg('grow', '4000')); // synthetic messages for the size arm
47const HEADED = process.argv.includes('--headed');
48
49const 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.
62const 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.
77function 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}
87if (BREAK && !BREAKS[BREAK]) {
88 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
89 process.exit(2);
90}
91
92const PW = process.env.DAIMOND_PW || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
93const pw = await import(pathToFileURL(PW).href);
94const sleep = ms => new Promise(r => setTimeout(r, ms));
95
96const iph = pw.devices['iPhone 13'];
97
98async 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.
120const 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.
167async 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.
176async 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.
183async 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".
209async 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.
242function 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
256async 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
272async 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
296async 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.
340async 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
355async 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.
377const 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.
404const 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.
421async 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
427const results = [];
428
429// ── SIZE: a big transcript, at once ──────────────────────────────────────
430if (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 ───────────────────────────────────────────────
457if (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.
481if (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.
549if (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 ───────────────────────────────────────────────────────────
598const dead = results.filter(r => r.v.dead);
599console.log('');
600if (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}
614if (!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}
620process.exit(0);