| 1 | // flash-frames.mjs — look at the flash instead of at a proxy for it. |
| 2 | // |
| 3 | // The previous investigation counted DOM mutations on reveal, found 21 |
| 4 | // redundant attribute writes, removed them, and reported the flash |
| 5 | // fixed. Paul still sees it. Counting mutations was measuring a proxy: |
| 6 | // it can go to zero while the thing a person actually sees is unchanged, |
| 7 | // because a flash is a PAINT, and paints and mutations are not the same |
| 8 | // list. |
| 9 | // |
| 10 | // So this records frames. Chromium's screencast hands back one PNG per |
| 11 | // painted frame; identical paints encode to identical bytes, so hashing |
| 12 | // each frame turns the reveal into a sequence of distinct visual states. |
| 13 | // If revealing the tab is visually instant there are two states at most |
| 14 | // (nothing, then the page). Anything more is the flash, and the frames |
| 15 | // themselves say what changed. |
| 16 | // |
| 17 | // The other correction is the scenario. Last time tab B never had focus |
| 18 | // — it loaded hidden and stayed hidden — so its toolbar was disabled |
| 19 | // before the switch and disabled after, and every write really was |
| 20 | // redundant. That is not what a person does. A person is TYPING in tab |
| 21 | // B, tabs away, and tabs back: the editor blurs and refocuses, and any |
| 22 | // state keyed on focus genuinely changes value in both directions. This |
| 23 | // drives that sequence. |
| 24 | |
| 25 | import { writeFileSync, mkdirSync } from 'node:fs' |
| 26 | import { createHash } from 'node:crypto' |
| 27 | |
| 28 | const [, , cdpBase, baseURL, outDir] = process.argv |
| 29 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) |
| 30 | mkdirSync(outDir, { recursive: true }) |
| 31 | |
| 32 | async function connect(wsUrl, { focusEmulation }) { |
| 33 | const ws = new WebSocket(wsUrl) |
| 34 | let nextId = 0 |
| 35 | const pending = new Map() |
| 36 | const listeners = new Map() |
| 37 | ws.onmessage = (e) => { |
| 38 | const m = JSON.parse(e.data) |
| 39 | if (m.id && pending.has(m.id)) { |
| 40 | const { resolve, reject } = pending.get(m.id) |
| 41 | pending.delete(m.id) |
| 42 | if (m.error) reject(new Error(JSON.stringify(m.error))) |
| 43 | else resolve(m.result) |
| 44 | return |
| 45 | } |
| 46 | const l = listeners.get(m.method) |
| 47 | if (l) for (const fn of l) fn(m.params) |
| 48 | } |
| 49 | await new Promise((res, rej) => { |
| 50 | ws.onopen = res |
| 51 | ws.onerror = rej |
| 52 | }) |
| 53 | const send = (method, params = {}) => |
| 54 | new Promise((resolve, reject) => { |
| 55 | const id = ++nextId |
| 56 | pending.set(id, { resolve, reject }) |
| 57 | ws.send(JSON.stringify({ id, method, params })) |
| 58 | }) |
| 59 | const on = (method, fn) => { |
| 60 | if (!listeners.has(method)) listeners.set(method, []) |
| 61 | listeners.get(method).push(fn) |
| 62 | } |
| 63 | await send('Runtime.enable') |
| 64 | await send('Page.enable') |
| 65 | if (focusEmulation) await send('Emulation.setFocusEmulationEnabled', { enabled: true }) |
| 66 | const evaluate = async (expression) => { |
| 67 | const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }) |
| 68 | if (r.exceptionDetails) { |
| 69 | throw new Error('page threw: ' + (r.exceptionDetails.exception?.description || '')) |
| 70 | } |
| 71 | return r.result.value |
| 72 | } |
| 73 | return { send, on, evaluate } |
| 74 | } |
| 75 | |
| 76 | const list = await fetch(`${cdpBase}/json/list`).then((r) => r.json()) |
| 77 | const pages = list.filter((t) => t.type === 'page') |
| 78 | if (pages.length < 2) { |
| 79 | console.error(`need two tabs, found ${pages.length}`) |
| 80 | process.exit(1) |
| 81 | } |
| 82 | |
| 83 | // A types while backgrounded, so it needs focus emulation. B is the tab |
| 84 | // under test and must NOT have it — its focus and visibility have to be |
| 85 | // the real thing, because that is the whole subject. |
| 86 | const A = await connect(pages[0].webSocketDebuggerUrl, { focusEmulation: true }) |
| 87 | const B = await connect(pages[1].webSocketDebuggerUrl, { focusEmulation: false }) |
| 88 | |
| 89 | // Find a document the way the app offers one: open the list and take |
| 90 | // the first row's link. The seeded database always has some. |
| 91 | await A.send('Page.navigate', { url: baseURL }) |
| 92 | let docURL = null |
| 93 | for (let i = 0; i < 200; i++) { |
| 94 | const href = await A.evaluate(`document.querySelector('a[href^="/d/"]')?.href || null`) |
| 95 | if (href) { docURL = href; break } |
| 96 | await sleep(100) |
| 97 | } |
| 98 | if (!docURL) { |
| 99 | console.error('no document link on the list page') |
| 100 | process.exit(1) |
| 101 | } |
| 102 | console.log('document:', docURL) |
| 103 | |
| 104 | for (const tab of [A, B]) await tab.send('Page.navigate', { url: docURL }) |
| 105 | for (const tab of [A, B]) { |
| 106 | for (let i = 0; i < 200; i++) { |
| 107 | if (await tab.evaluate(`!!document.querySelector('[docs-editor]')`)) break |
| 108 | await sleep(100) |
| 109 | } |
| 110 | } |
| 111 | await sleep(1500) |
| 112 | |
| 113 | // Give B a real caret, the way a person has one before they tab away. |
| 114 | await B.send('Page.bringToFront') |
| 115 | await sleep(300) |
| 116 | const clickEditor = async (tab) => { |
| 117 | const at = await tab.evaluate(`(() => { |
| 118 | const el = document.querySelector('[docs-editor]') |
| 119 | const r = el.getBoundingClientRect() |
| 120 | return { x: Math.round(r.left + 40), y: Math.round(r.top + 20) } |
| 121 | })()`) |
| 122 | for (const type of ['mousePressed', 'mouseReleased']) { |
| 123 | await tab.send('Input.dispatchMouseEvent', { |
| 124 | type, x: at.x, y: at.y, button: 'left', clickCount: 1, |
| 125 | }) |
| 126 | } |
| 127 | } |
| 128 | const typeText = async (tab, text) => { |
| 129 | for (const ch of text) { |
| 130 | await tab.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch }) |
| 131 | await tab.send('Input.dispatchKeyEvent', { type: 'keyUp', text: ch, unmodifiedText: ch }) |
| 132 | } |
| 133 | } |
| 134 | await clickEditor(B) |
| 135 | await typeText(B, 'SEED') |
| 136 | await sleep(1200) |
| 137 | |
| 138 | console.log('tab B has a caret:', await B.evaluate(`document.activeElement?.hasAttribute('docs-editor') || document.querySelector('[docs-editor]').contains(document.activeElement)`)) |
| 139 | console.log('tab B toolbar disabled before switching away:', await B.evaluate(` |
| 140 | [...document.querySelectorAll('[docs-tool]')].filter(b => b.disabled).length |
| 141 | + ' of ' + document.querySelectorAll('[docs-tool]').length`)) |
| 142 | |
| 143 | // Now the switch away. A comes to the front; B is genuinely hidden and |
| 144 | // its editor genuinely blurs, which is the state the last harness never |
| 145 | // produced. |
| 146 | await A.send('Page.bringToFront') |
| 147 | await sleep(500) |
| 148 | await clickEditor(A) |
| 149 | const marker = 'MARK' + Math.floor(Math.random() * 10000) |
| 150 | await typeText(A, marker) |
| 151 | console.log(`typed ${marker} in tab A`) |
| 152 | await sleep(1500) |
| 153 | |
| 154 | // The decisive reading, and the one the first version of this harness |
| 155 | // did not take. A stale FRAME on reveal has two completely different |
| 156 | // causes with the same appearance: the DOM is still old (the tab never |
| 157 | // received the edit while hidden), or the DOM is already current and |
| 158 | // the browser is briefly showing a cached compositor surface from |
| 159 | // before. Only the first is this app's bug, and only this line tells |
| 160 | // them apart — so it is read while still hidden, before the reveal can |
| 161 | // change the answer. |
| 162 | console.log('tab B DOM already has the marker WHILE HIDDEN:', |
| 163 | await B.evaluate(`document.querySelector('[docs-editor]').innerText`).then((t) => t.includes(marker))) |
| 164 | console.log('tab B visibilityState while away:', await B.evaluate('document.visibilityState')) |
| 165 | console.log('tab B toolbar disabled while away: ', await B.evaluate(` |
| 166 | [...document.querySelectorAll('[docs-tool]')].filter(b => b.disabled).length |
| 167 | + ' of ' + document.querySelectorAll('[docs-tool]').length`)) |
| 168 | |
| 169 | // Record what the DOM does too, so a frame that changes can be matched |
| 170 | // to a cause rather than only described. |
| 171 | await B.evaluate(`(() => { |
| 172 | window.__log = [] |
| 173 | const t0 = performance.now() |
| 174 | const stamp = (what) => window.__log.push(Math.round(performance.now() - t0) + 'ms ' + what) |
| 175 | for (const ev of ['visibilitychange', 'focus', 'blur']) { |
| 176 | document.addEventListener(ev, () => stamp('document ' + ev + ' (' + document.visibilityState + ')'), true) |
| 177 | } |
| 178 | const ed = document.querySelector('[docs-editor]') |
| 179 | ed.addEventListener('focus', () => stamp('editor focus'), true) |
| 180 | ed.addEventListener('blur', () => stamp('editor blur'), true) |
| 181 | new MutationObserver((muts) => { |
| 182 | for (const m of muts) { |
| 183 | if (m.type === 'attributes') { |
| 184 | const now = m.target.getAttribute(m.attributeName) |
| 185 | if (m.oldValue === now) continue |
| 186 | stamp('ATTR ' + m.target.nodeName.toLowerCase() |
| 187 | + (m.target.getAttribute('data-docs-command') ? '[' + m.target.getAttribute('data-docs-command') + ']' : '') |
| 188 | + ' ' + m.attributeName + ': ' + JSON.stringify(m.oldValue) + ' -> ' + JSON.stringify(now)) |
| 189 | } else if (m.addedNodes.length || m.removedNodes.length) { |
| 190 | stamp('NODES on ' + m.target.nodeName.toLowerCase() |
| 191 | + ' +' + m.addedNodes.length + ' -' + m.removedNodes.length) |
| 192 | } |
| 193 | } |
| 194 | }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeOldValue: true }) |
| 195 | window.__t0 = t0 |
| 196 | return true |
| 197 | })()`) |
| 198 | |
| 199 | // What was actually ON SCREEN, frame by frame. requestAnimationFrame |
| 200 | // does not run in a hidden tab and resumes with the first painted frame |
| 201 | // after reveal, so this samples exactly the thing a person sees — and |
| 202 | // unlike the screencast, which only emits when the page happens to |
| 203 | // paint and cannot resolve a 20ms window, it records every frame the |
| 204 | // browser draws. Each entry says whether the caught-up text was on |
| 205 | // screen for that frame. |
| 206 | await B.evaluate(`(() => { |
| 207 | window.__paints = [] |
| 208 | const ed = document.querySelector('[docs-editor]') |
| 209 | const marker = ${JSON.stringify(marker)} |
| 210 | const start = performance.now() |
| 211 | const tick = () => { |
| 212 | window.__paints.push({ |
| 213 | at: Math.round(performance.now() - start), |
| 214 | fresh: ed.innerText.includes(marker), |
| 215 | }) |
| 216 | if (window.__paints.length < 120) requestAnimationFrame(tick) |
| 217 | } |
| 218 | requestAnimationFrame(tick) |
| 219 | return true |
| 220 | })()`) |
| 221 | |
| 222 | // Frames. Started before the reveal so the first painted frame after it |
| 223 | // is captured rather than missed. |
| 224 | const frames = [] |
| 225 | let castStarted = 0 |
| 226 | B.on('Page.screencastFrame', async (p) => { |
| 227 | frames.push({ at: Date.now(), data: p.data }) |
| 228 | try { |
| 229 | await B.send('Page.screencastFrameAck', { sessionId: p.sessionId }) |
| 230 | } catch { |
| 231 | /* the cast is stopped below; a late ack losing its target is not a finding */ |
| 232 | } |
| 233 | }) |
| 234 | castStarted = Date.now() |
| 235 | await B.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 }) |
| 236 | |
| 237 | // A deliberate gap between starting the cast and revealing the tab. |
| 238 | // Chromium can hand a screencast the last composited surface it still |
| 239 | // holds — a picture of the tab as it looked before it was hidden — and |
| 240 | // that arrives as an ordinary frame, indistinguishable from a paint. If |
| 241 | // any frame lands in this window, while the tab is provably still |
| 242 | // hidden and therefore painting nothing, then a "stale first frame" on |
| 243 | // reveal is this harness looking at a cached surface rather than |
| 244 | // anything a person would see, and reading it as the flash would be |
| 245 | // measuring the instrument. |
| 246 | await sleep(800) |
| 247 | const framesBeforeReveal = frames.length |
| 248 | console.log(`frames delivered while STILL HIDDEN, before any reveal: ${framesBeforeReveal}`) |
| 249 | |
| 250 | console.log('--- revealing tab B ---') |
| 251 | const revealed = Date.now() |
| 252 | await B.send('Page.bringToFront') |
| 253 | await sleep(2500) |
| 254 | await B.send('Page.stopScreencast') |
| 255 | |
| 256 | const paints = await B.evaluate('window.__paints') |
| 257 | const stale = paints.filter((p) => !p.fresh) |
| 258 | console.log(`\nPainted frames after reveal: ${paints.length}`) |
| 259 | console.log(`Frames showing the OLD text: ${stale.length}` + |
| 260 | (stale.length ? ` (through +${stale.at(-1).at}ms — this is the flash)` : ' — the first painted frame was already current')) |
| 261 | |
| 262 | const log = await B.evaluate('window.__log') |
| 263 | console.log('\nDOM during the reveal (changed values only):') |
| 264 | if (!log.length) console.log(' (nothing)') |
| 265 | for (const line of log) console.log(' ' + line) |
| 266 | |
| 267 | console.log(`\ntab B sees the marker: ${await B.evaluate(`document.querySelector('[docs-editor]').innerText`).then((t) => t.includes(marker))}`) |
| 268 | |
| 269 | // Identical paints encode identically, so a hash per frame collapses the |
| 270 | // cast into the sequence of distinct visual states. |
| 271 | const seen = new Map() |
| 272 | const sequence = [] |
| 273 | for (const f of frames) { |
| 274 | const buf = Buffer.from(f.data, 'base64') |
| 275 | const h = createHash('sha256').update(buf).digest('hex').slice(0, 12) |
| 276 | if (!seen.has(h)) { |
| 277 | const name = `${outDir}/state-${String(seen.size).padStart(2, '0')}-${h}.png` |
| 278 | writeFileSync(name, buf) |
| 279 | seen.set(h, { name, first: f.at - revealed }) |
| 280 | } |
| 281 | if (sequence.at(-1)?.hash !== h) { |
| 282 | sequence.push({ hash: h, at: f.at - revealed, hidden: f.at < revealed }) |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | console.log(`\n${frames.length} frames captured, ${seen.size} visually distinct`) |
| 287 | for (const s of sequence) { |
| 288 | console.log(` ${s.hidden ? 'HIDDEN ' : '+'}${s.at}ms ${s.hash} ${seen.get(s.hash).name}`) |
| 289 | } |
| 290 | process.exit(0) |
| 291 | |