oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

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
25import { writeFileSync, mkdirSync } from 'node:fs'
26import { createHash } from 'node:crypto'
27
28const [, , cdpBase, baseURL, outDir] = process.argv
29const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
30mkdirSync(outDir, { recursive: true })
31
32async 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
76const list = await fetch(`${cdpBase}/json/list`).then((r) => r.json())
77const pages = list.filter((t) => t.type === 'page')
78if (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.
86const A = await connect(pages[0].webSocketDebuggerUrl, { focusEmulation: true })
87const 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.
91await A.send('Page.navigate', { url: baseURL })
92let docURL = null
93for (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}
98if (!docURL) {
99 console.error('no document link on the list page')
100 process.exit(1)
101}
102console.log('document:', docURL)
103
104for (const tab of [A, B]) await tab.send('Page.navigate', { url: docURL })
105for (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}
111await sleep(1500)
112
113// Give B a real caret, the way a person has one before they tab away.
114await B.send('Page.bringToFront')
115await sleep(300)
116const 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}
128const 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}
134await clickEditor(B)
135await typeText(B, 'SEED')
136await sleep(1200)
137
138console.log('tab B has a caret:', await B.evaluate(`document.activeElement?.hasAttribute('docs-editor') || document.querySelector('[docs-editor]').contains(document.activeElement)`))
139console.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.
146await A.send('Page.bringToFront')
147await sleep(500)
148await clickEditor(A)
149const marker = 'MARK' + Math.floor(Math.random() * 10000)
150await typeText(A, marker)
151console.log(`typed ${marker} in tab A`)
152await 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.
162console.log('tab B DOM already has the marker WHILE HIDDEN:',
163 await B.evaluate(`document.querySelector('[docs-editor]').innerText`).then((t) => t.includes(marker)))
164console.log('tab B visibilityState while away:', await B.evaluate('document.visibilityState'))
165console.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.
171await 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.
206await 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.
224const frames = []
225let castStarted = 0
226B.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})
234castStarted = Date.now()
235await 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.
246await sleep(800)
247const framesBeforeReveal = frames.length
248console.log(`frames delivered while STILL HIDDEN, before any reveal: ${framesBeforeReveal}`)
249
250console.log('--- revealing tab B ---')
251const revealed = Date.now()
252await B.send('Page.bringToFront')
253await sleep(2500)
254await B.send('Page.stopScreencast')
255
256const paints = await B.evaluate('window.__paints')
257const stale = paints.filter((p) => !p.fresh)
258console.log(`\nPainted frames after reveal: ${paints.length}`)
259console.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
262const log = await B.evaluate('window.__log')
263console.log('\nDOM during the reveal (changed values only):')
264if (!log.length) console.log(' (nothing)')
265for (const line of log) console.log(' ' + line)
266
267console.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.
271const seen = new Map()
272const sequence = []
273for (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
286console.log(`\n${frames.length} frames captured, ${seen.size} visually distinct`)
287for (const s of sequence) {
288 console.log(` ${s.hidden ? 'HIDDEN ' : '+'}${s.at}ms ${s.hash} ${seen.get(s.hash).name}`)
289}
290process.exit(0)
291