From 433a22d081753cd0350f8459666a71c7ba4214c6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 7 Aug 2026 11:55:44 -0700 Subject: [PATCH] [eric] qa: a dead-control sweep clicks every control and fails when nothing happens --- scripts/ui-sweep.js | 189 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 scripts/ui-sweep.js diff --git a/scripts/ui-sweep.js b/scripts/ui-sweep.js new file mode 100644 index 00000000..604035d9 --- /dev/null +++ b/scripts/ui-sweep.js @@ -0,0 +1,189 @@ +/** + * Dead-control sweep: clicks every interactive control on a surface with REAL mouse events and + * asserts something actually happened. + * + * This exists because of the dictation shortcut chip (ENG-183): the handler was attached, the + * hit-test landed inside the element, every native event fired, nothing stopped propagation, and + * the control was still dead because React never dispatched. Nothing in tsc, the linter, unit tests, + * or a screenshot can see that. Only clicking it can. + * + * Two rules it enforces on itself, both learned by getting them wrong: + * 1. Confirm the hit point with elementFromPoint BEFORE dispatching. Locating a control by DOM + * scan hands you elements that something else is painting over. + * 2. A control is only "alive" if the click causes an OBSERVABLE change (DOM mutation, focus move, + * or a React handler firing). "It didn't throw" is not evidence. + * + * node scripts/ui-sweep.js [surfaceName] [restoreScriptPath] + * + * restoreScriptPath is a file holding a JS expression that puts the surface back in its canonical + * state. It is needed because clicking a control can navigate away, and every control after that + * would otherwise be scored against the wrong screen. + */ + +const WS_PATH = require('path').join(__dirname, '..', 'frontend', 'node_modules', 'ws'); +const WebSocket = require(WS_PATH); +const http = require('http'); + +const PORT = process.argv[2] || '9223'; +const SURFACE = process.argv[3] || 'current'; +const RESTORE = process.argv[4] ? require('fs').readFileSync(process.argv[4], 'utf8') : ''; + +function targets() { + return new Promise((res, rej) => { + http.get(`http://127.0.0.1:${PORT}/json/list`, (r) => { + let d = ''; + r.on('data', (c) => (d += c)); + r.on('end', () => res(JSON.parse(d))); + }).on('error', rej); + }); +} + +// Records, for one control: did the DOM change, did focus move, did a React handler run. +const INSTRUMENT = `(function(){ + window.__SWEEP__ = window.__SWEEP__ || {}; + window.__SWEEP__.find = function(tag, label, ord){ + var sel = 'button,[role=button],[tabindex="0"],input,select,textarea,[role=tab],[role=switch],[role=menuitem]'; + var seen = 0; + var all = document.querySelectorAll(sel); + for (var i = 0; i < all.length; i++) { + var n = all[i]; + if (n.tagName !== tag) continue; + var l = (n.getAttribute('aria-label') || n.innerText || n.value || '').trim().replace(/\s+/g,' ').slice(0,34); + if (l !== label) continue; + seen++; + if (seen === ord) return n; + } + return null; + }; + window.__SWEEP__.arm = function(el){ + var s = { mutated:false, focusMoved:false, reactFired:false, nativeFired:false }; + window.__SWEEP__.state = s; + var before = document.activeElement; + var obs = new MutationObserver(function(){ s.mutated = true; }); + obs.observe(document.body, { subtree:true, childList:true, attributes:true, characterData:true }); + window.__SWEEP__.stop = function(){ + obs.disconnect(); + s.focusMoved = document.activeElement !== before; + return s; + }; + el.addEventListener('click', function(){ s.nativeFired = true; }, { once:true, capture:true }); + var pk = Object.keys(el).find(function(k){ return k.indexOf('__reactProps$') === 0; }); + if (pk && el[pk] && typeof el[pk].onClick === 'function') { + var orig = el[pk].onClick; + el[pk].onClick = function(){ s.reactFired = true; return orig.apply(this, arguments); }; + s.hasReactOnClick = true; + } else { + s.hasReactOnClick = false; + } + return true; + }; + return 1; +})()`; + +// Interactive = something a user would expect to respond. Filtered in CSS px because canvas cards +// are zoom-scaled and an on-screen size filter silently skips small-but-real controls. +const ENUM = `(function(){ + var out = [], keep = []; + var sel = 'button,[role=button],[tabindex="0"],input,select,textarea,[role=tab],[role=switch],[role=menuitem]'; + document.querySelectorAll(sel).forEach(function(n){ + if (n.disabled) return; + var r = n.getBoundingClientRect(); + if (r.width < 4 || r.height < 4) return; + if (r.bottom < 4 || r.top > innerHeight - 4 || r.right < 4 || r.left > innerWidth - 4) return; + var cs = getComputedStyle(n); + if (cs.visibility === 'hidden' || cs.pointerEvents === 'none' || parseFloat(cs.opacity) < 0.05) return; + var x = Math.round(r.left + r.width/2), y = Math.round(r.top + r.height/2); + var hit = document.elementFromPoint(x, y); + var reachable = !!(hit && (n.contains(hit) || n === hit)); + keep.push(n); + out.push({ + i: keep.length - 1, x: x, y: y, reachable: reachable, + tag: n.tagName, role: n.getAttribute('role') || '', + label: (n.getAttribute('aria-label') || n.innerText || n.value || '').trim().replace(/\\s+/g,' ').slice(0, 34), + ord: (function(){ var c=0; for (var k=0;k { + const list = await targets(); + const page = list.find((t) => t.type === 'page' && t.url.includes(':3000')); + if (!page) { console.log('no :3000 page'); process.exit(1); } + const ws = new WebSocket(page.webSocketDebuggerUrl, { perMessageDeflate: false }); + await new Promise((r) => ws.on('open', r)); + let id = 0; const pend = new Map(); + ws.on('message', (m) => { const g = JSON.parse(m); if (g.id && pend.has(g.id)) { pend.get(g.id)(g); pend.delete(g.id); } }); + const send = (m, p = {}) => new Promise((res) => { const i = ++id; pend.set(i, res); ws.send(JSON.stringify({ id: i, method: m, params: p })); }); + const ev = async (e) => { const r = await send('Runtime.evaluate', { expression: e, returnByValue: true, awaitPromise: true }); return r.result && r.result.result ? r.result.result.value : undefined; }; + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + + await ev(INSTRUMENT); + const controls = JSON.parse(await ev(ENUM) || '[]'); + console.log(`\nUI sweep: ${SURFACE} -- ${controls.length} interactive controls on screen\n`); + + const dead = [], occluded = [], alive = [], stale = []; + for (const ctl of controls) { + if (!ctl.reachable) { occluded.push(ctl); continue; } + // Park the pointer and clear transients first: a tooltip left hovering over the previous control + // covers the next one, and a modal opened by an earlier click hides the whole surface behind it. + await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: 2, y: 2 }); + await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }); + await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27, nativeVirtualKeyCode: 27 }); + await sleep(220); + // Re-resolve position at click time. Coordinates captured during enumeration go stale the moment + // an earlier click re-renders, and a click into empty space reads exactly like a dead control. + const fresh = JSON.parse(await ev(`(function(){ + var n = window.__SWEEP__.find(${JSON.stringify(ctl.tag)}, ${JSON.stringify(ctl.label)}, ${ctl.ord}); + if (!n || !n.isConnected) return JSON.stringify({gone:true}); + window.__SWEEP__.current = n; + var r = n.getBoundingClientRect(); + if (r.width < 4 || r.height < 4) return JSON.stringify({gone:true}); + var x = Math.round(r.left + r.width/2), y = Math.round(r.top + r.height/2); + if (x < 2 || y < 2 || x > innerWidth - 2 || y > innerHeight - 2) return JSON.stringify({offscreen:true}); + var hit = document.elementFromPoint(x, y); + return JSON.stringify({ x:x, y:y, ok: !!(hit && (n.contains(hit) || n === hit)), + cover: hit ? hit.tagName + '.' + String(hit.className||'').slice(0,26) : 'nothing' }); + })()`) || '{"gone":true}'); + if (fresh.gone) { stale.push({ ...ctl, why: 'unmounted before its turn' }); continue; } + if (fresh.offscreen) { stale.push({ ...ctl, why: 'scrolled off screen before its turn' }); continue; } + if (!fresh.ok) { occluded.push({ ...ctl, x: fresh.x, y: fresh.y, occludedBy: fresh.cover }); continue; } + await ev('window.__SWEEP__.arm(window.__SWEEP__.current)'); + await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: fresh.x, y: fresh.y, button: 'left', clickCount: 1 }); + await sleep(40); + await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: fresh.x, y: fresh.y, button: 'left', clickCount: 1 }); + await sleep(260); + const s = await ev('JSON.stringify(window.__SWEEP__.stop())'); + const st = JSON.parse(s || '{}'); + const responded = st.mutated || st.focusMoved || st.reactFired; + // A click can navigate; without putting the surface back, every later control is judged against + // the wrong screen and lands in "inconclusive". + if (RESTORE) { await ev(RESTORE); await sleep(600); } + // A control with a React onClick that never fires on a real click is the ENG-183 signature. + // nativeFired false means the click never even reached the node: that is a harness miss, not a + // dead control, and reporting it as a bug is how you cry wolf. + if (!st.nativeFired) { stale.push({ ...ctl, why: 'click did not land (moved mid-sweep)' }); continue; } + if (!responded) dead.push({ ...ctl, ...st }); + else alive.push(ctl); + } + + console.log(`alive: ${alive.length} dead: ${dead.length} occluded: ${occluded.length} inconclusive: ${stale.length}\n`); + if (dead.length) { + console.log('DEAD CONTROLS (clicked, nothing observable happened):'); + for (const d of dead) { + console.log(` ${d.tag}${d.role ? '[' + d.role + ']' : ''} "${d.label}" @(${d.x},${d.y})` + + ` reactOnClick=${d.hasReactOnClick} reactFired=${d.reactFired} nativeFired=${d.nativeFired}`); + } + console.log(''); + } + if (occluded.length) { + console.log('OCCLUDED (a user cannot click these where they are painted):'); + for (const o of occluded) console.log(` ${o.tag} "${o.label}" @(${o.x},${o.y}) covered by ${o.occludedBy}`); + console.log(''); + } + ws.close(); + process.exit(dead.length ? 1 : 0); +})().catch((e) => { console.log('sweep error: ' + e.message); process.exit(2); });