/** * The look() element list is capped so a pathological page can't flood the model — but the cap must * NEVER drop the one button the walk exists to click. A page's primary CTA (Generate, Publish, * Publiceren, Download, Save…) is almost always a bottom-right or sticky button that sorts LAST in * reading order, so an early top-left cap sliced it off and the agent looped "60 things to click" * forever while a human saw the button plainly (ElevenLabs Generate, YouTube Studio Publiceren). * Proven to be the cap and not the model: a weak model and a strong model both typed the text then * failed to click the exact same missing button. This guards the fix: action buttons and text * fields are kept regardless of the cap; ordinary clickables past the cap are still pruned. */ import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'url'; import { extractElements, dismissConsent } from '../src/inspector.js'; const el = (text, top, left, extra = {}) => ({ tag: 'button', type: null, id: null, selector: null, ariaLabel: '', placeholder: null, role: null, text, href: null, editable: false, on: false, left, top, width: 80, height: 30, ...extra, }); // A fake page: one main frame whose FRAME_EXTRACTOR returns `raw`, and a page.evaluate that answers // the viewport-size probes. extractElements maps + clips + caps this exactly as it would a real page. const mkPage = (raw) => { const main = { evaluate: async () => raw }; return { mainFrame: () => main, frames: () => [main], evaluate: async (fn) => { const s = String(fn); if (s.includes('innerWidth')) return 1280; if (s.includes('innerHeight')) return 800; return undefined; }, }; }; describe('the look() cap never drops the primary action button', () => { it('keeps a Generate CTA that sorts dead-last, past the cap', async () => { const raw = []; for (let i = 0; i < 92; i++) raw.push(el(`item ${i}`, i * 8, 10)); // 92 generics, tops 0..728 raw.push(el('↑ Generate', 790, 1100)); // the CTA, sorts LAST const out = await extractElements(mkPage(raw)); const gen = out.find((e) => /generate/i.test(e.text)); expect(gen, 'Generate must survive the cap').toBeTruthy(); expect(typeof gen.index).toBe('number'); expect(out.length).toBeLessThanOrEqual(90); // cap still bounds the list // …and the cap still prunes ordinary clickables that fall past it, so this isn't just "keep all". expect(out.find((e) => e.text === 'item 90'), 'a generic button past the cap is still pruned').toBeUndefined(); }); it('recognises Dutch and other real CTAs, not only English "Generate"', async () => { for (const cta of ['Publiceren', 'Download', 'Opslaan', 'Volgende', 'Gereed']) { const raw = []; for (let i = 0; i < 95; i++) raw.push(el(`row ${i}`, i * 8, 10)); raw.push(el(cta, 790, 1100)); const out = await extractElements(mkPage(raw)); expect(out.find((e) => e.text === cta), `${cta} must survive the cap`).toBeTruthy(); } }); it('a long paragraph that happens to contain an action word is NOT treated as a CTA', async () => { const raw = []; for (let i = 0; i < 95; i++) raw.push(el(`row ${i}`, i * 8, 10)); // A blurb, not a button — must not sneak past the cap just because it says "generate". raw.push(el('Our studio can generate a full video for you in minutes with AI voiceover', 790, 1100)); const out = await extractElements(mkPage(raw)); expect(out.find((e) => /Our studio/.test(e.text))).toBeUndefined(); }); it('still keeps text fields even when scrolled off-screen (existing guarantee intact)', async () => { const raw = []; for (let i = 0; i < 95; i++) raw.push(el(`row ${i}`, i * 8, 10)); raw.push(el('', 2000, 10, { editable: true, tag: 'textarea', text: 'message' })); // off-screen field const out = await extractElements(mkPage(raw)); expect(out.find((e) => e.editable), 'an off-screen field is never dropped').toBeTruthy(); }); it('keeps a Run button that has scrolled ABOVE the viewport (AI Studio top-right case)', async () => { const raw = []; for (let i = 0; i < 50; i++) raw.push(el(`item ${i}`, i * 10, 10)); // all in view raw.push(el('Run Ctrl ↵', -80, 1100)); // scrolled off the TOP const out = await extractElements(mkPage(raw)); expect(out.find((e) => /run/i.test(e.text)), 'off-screen Run must survive').toBeTruthy(); }); it('keeps a Generate below the fold (off-screen from the start)', async () => { const raw = []; for (let i = 0; i < 95; i++) raw.push(el(`item ${i}`, i * 8, 10)); // fill the viewport raw.push(el('Generate', 5000, 1100)); // far below the fold const out = await extractElements(mkPage(raw)); expect(out.find((e) => e.text === 'Generate'), 'below-the-fold Generate must survive').toBeTruthy(); }); it('treats an input styled as submit/button as an action', async () => { const raw = []; for (let i = 0; i < 95; i++) raw.push(el(`item ${i}`, i * 8, 10)); raw.push(el('', 5000, 1100, { tag: 'input', type: 'submit', text: '' })); // labelless submit, off-screen const out = await extractElements(mkPage(raw)); expect(out.find((e) => e.tag === 'input' && e.type === 'submit'), 'a submit input must survive').toBeTruthy(); }); it('keeps EVERY "Lid worden" join button on a groups results page, not just the first (batch-join)', async () => { // The live bug: a Facebook groups search is a page of "Lid worden" buttons. The top nav + left // filters fill the reading-order budget first, so a plain cap kept only the FIRST row's join // button — the agent could not click the rest and kept RE-SEARCHING. As actions, all must survive. const raw = []; for (let i = 0; i < 85; i++) raw.push(el(`chrome ${i}`, 10 + (i % 20) * 5, 120)); // nav + filters for (let r = 0; r < 6; r++) raw.push(el('Lid worden', 200 + r * 90, 1500)); // six group rows const out = await extractElements(mkPage(raw)); const joins = out.filter((e) => e.text === 'Lid worden'); expect(joins.length, 'all six join buttons must survive the cap').toBe(6); }); it('collapses a nested button (wrapper + inner spans) into ONE numbered box', async () => { const raw = []; for (let i = 0; i < 20; i++) raw.push(el(`row ${i}`, i * 8, 10)); // one "Lid worden" control rendered as 4 overlapping nested boxes at the same spot raw.push(el('Lid worden', 300, 1500, { width: 120, height: 40 })); // wrapper (largest) raw.push(el('Lid worden', 302, 1508, { width: 90, height: 24 })); // inner span raw.push(el('Lid worden', 305, 1510, { width: 60, height: 20 })); // inner text raw.push(el('Lid worden', 306, 1512, { width: 40, height: 16 })); // icon+label // a DISTINCT join button on another row, far away — must NOT be merged raw.push(el('Lid worden', 520, 1500, { width: 120, height: 40 })); const out = await extractElements(mkPage(raw)); const joins = out.filter((e) => e.text === 'Lid worden'); expect(joins.length, 'nested cluster collapses to one; distinct button kept').toBe(2); }); it('recognises join/follow/connect labels across languages (Deelnemen, Volgen, Connect)', async () => { for (const cta of ['Deelnemen', 'Volgen', 'Connect', 'Abonneren']) { const raw = []; for (let i = 0; i < 95; i++) raw.push(el(`row ${i}`, i * 8, 10)); raw.push(el(cta, 790, 1100)); const out = await extractElements(mkPage(raw)); expect(out.find((e) => e.text === cta), `${cta} must survive the cap`).toBeTruthy(); } }); }); describe('consent banners are dismissed so they cannot cover the main button', () => { const { JSDOM } = (() => { try { return require('jsdom'); } catch { return {}; } })(); // A fake page whose one frame runs the clicker with `document` bound to a jsdom doc holding a bar. const mkConsentPage = (html) => { if (!JSDOM) return null; const dom = new JSDOM(html); const g = dom.window; // jsdom has no layout, so getBoundingClientRect is all-zero — stub a real size so the guard passes. g.HTMLElement.prototype.getBoundingClientRect = function () { return { width: 120, height: 40, top: 700, left: 10, right: 130, bottom: 740 }; }; const frame = { evaluate: async (fn) => { global.document = g.document; try { return fn(); } finally { delete global.document; } } }; return { frames: () => [frame], _doc: g.document }; }; it('clicks an exact consent phrase (OK, got it) and leaves other buttons alone', async () => { const page = mkConsentPage('
'); if (!page) return; // jsdom absent — the extract tests above still guard the cap/off-screen fixes let clicked = null; page._doc.getElementById('consent').addEventListener('click', () => { clicked = 'consent'; }); page._doc.getElementById('run').addEventListener('click', () => { clicked = 'run'; }); const hit = await dismissConsent(page); expect(hit).toBe('ok, got it'); expect(clicked, 'only the consent button is clicked, never Run').toBe('consent'); }); it('returns null when there is no consent banner', async () => { const page = mkConsentPage('
'); if (!page) return; const hit = await dismissConsent(page); expect(hit).toBeNull(); }); }); describe('source guards: the cap was raised and CTAs are recognised', () => { const src = fs.readFileSync( path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'inspector.js'), 'utf8'); it('the cap is 90, not the old 60', () => { expect(src).toContain('const CAP = 90'); }); it('the action-word set covers the buttons that were being missed', () => { const low = src.toLowerCase(); for (const w of ['generate', 'publish', 'publiceren', 'download', 'save', 'opslaan']) { expect(low, w).toContain(`'${w}'`); } }); }); /* * THE WALK WENT BLIND THE MOMENT A DIALOG OPENED. * * Measured live on the cover-photo flow. The walk clicked "Wijzigen", Facebook opened its cover * editor over the page, and the next read said "48 things to click" — of which the editor's own * controls were almost none. The numbers had gone on what was BEHIND the modal: the left rail, the * tabs, the composer, the whole page chrome, every bit of it inert and every bit of it sorting first * in reading order. The one thing inside the dialog the reader did surface was "Annuleren", so the * walk pressed it, reopened the dialog, and pressed it again. Twice in the transcript, on its way to * forever. * * Raising the cap would not have fixed it. The background is not lower-priority, it is UNCLICKABLE * while a modal is up — which the browser knows, a person knows, and the accessibility tree states * outright. The reader now states it too: when a dialog is open, the dialog is the page. */ const modalPage = (raw, { modal = false, title = '' } = {}) => { const main = { evaluate: async () => ({ elements: raw, modal, title }) }; return { mainFrame: () => main, frames: () => [main], evaluate: async (fn) => { const s = String(fn); if (s.includes('innerWidth')) return 1280; if (s.includes('innerHeight')) return 800; return undefined; }, }; }; describe('when a dialog owns the screen, the read says so', () => { it('the list carries the flag through, and the dialog is named', async () => { const out = await extractElements(modalPage([el('Opslaan', 300, 500), el('Annuleren', 300, 620)], { modal: true, title: 'Omslagfoto bewerken' })); expect(out.modal).toBe(true); expect(out.modalTitle).toBe('Omslagfoto bewerken'); expect(out).toHaveLength(2); }); it('and an ordinary page is untouched — no flag, nothing hidden', async () => { const out = await extractElements(modalPage([el('Home', 10, 10), el('Opslaan', 300, 500)])); expect(out.modal).toBeFalsy(); expect(out).toHaveLength(2); }); /* The old shape must still read, or a frame answering either way is dropped entirely. */ it('a frame that answers with a bare list is still read', async () => { const out = await extractElements(mkPage([el('Home', 10, 10)])); expect(out).toHaveLength(1); expect(out.modal).toBeFalsy(); }); }); /* * AND THE MODEL IS TOLD, because a read that drops from 50 controls to 12 looks like a broken page, * and the walk's next move is to go hunting for what it "lost" — which is the same loop by another * route. The scoping is only half the fix; saying why is the other half. */ describe('the source says what the reader promises', () => { const src = fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'inspector.js'), 'utf8'); it('the scope is the topmost dialog', () => { expect(src).toMatch(/const top = open\[open\.length - 1\];/); expect(src).toMatch(/\[aria-modal="true"\], dialog\[open\], \[role="dialog"\], \[role="alertdialog"\]/); }); /* A hidden dialog is not open, and a tooltip-sized one is not a dialog. Both would blind the walk. */ it('a hidden or tiny role=dialog is not treated as one', () => { expect(src).toMatch(/if \(d\.closest\('\[aria-hidden="true"\],\[inert\]'\)\) return false;/); expect(src).toMatch(/if \(r\.width < 200 \|\| r\.height < 120\) return false;/); }); /* A select inside a modal renders its listbox in a portal at the end of body, OUTSIDE the dialog. */ it('a popup layer opened after the dialog is included with it', () => { expect(src).toMatch(/role="listbox"/); expect(src).toMatch(/DOCUMENT_POSITION_FOLLOWING/); }); it('and the summary tells the model the page behind is unclickable', () => { expect(src).toMatch(/A DIALOG IS OPEN/); expect(src).toMatch(/the page behind it cannot be clicked while it is up/); }); }); /* * ── A DIALOG THAT IS BUILT BUT NOT SHOWN MUST NOT BECOME THE PAGE ──────────────────────────────── * * `display: none` and `visibility: hidden` were the only two ways of being hidden this knew about, * and a single-page application has several more. Search Console keeps its panels in the document * and hides them with opacity, or parks them outside the window. So every read of the performance * report came back as "Verwijderingen — 14 things to click (inside the open dialog)": the walk * opened the right address, was told a Removals dialog covered it, closed a dialog that was not * there, looked again, got the same answer. Twenty steps of a loop with nothing wrong on screen. * * The scoping itself is right and stays — a real modal must blind the walk to the page behind it, * which is why it was written. What was missing is the difference between present and shown. */ describe('a dialog must actually be in front to become the page', () => { const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/inspector.js'), 'utf8'); it('faded out or unclickable is not shown', () => { expect(src).toMatch(/if \(Number\(st\.opacity\) === 0\) return false;/); expect(src).toMatch(/if \(st\.pointerEvents === 'none'\) return false;/); }); it('and parked outside the window is not covering anything', () => { expect(src).toMatch(/if \(r\.right <= 0 \|\| r\.bottom <= 0 \|\| r\.left >= vw \|\| r\.top >= vh\) return false;/); }); /* The deciding test: a modal covers the page, so the point at its centre belongs to it. */ it('the centre of a real modal hits the modal', () => { expect(src).toMatch(/const at = document\.elementFromPoint\(cx, cy\);/); expect(src).toMatch(/if \(at && at !== d && !d\.contains\(at\) && !at\.contains\(d\)\) return false;/); }); /* * ONLY A DEFINITE ANSWER EXCLUDES IT. Wrongly ignoring a real modal is how the walk went blind on * Facebook, which is the failure this whole scope exists to prevent — so an unanswerable hit test * keeps the dialog rather than dropping it. */ it('and an unanswerable hit test keeps the dialog rather than dropping it', () => { expect(src).toMatch(/the dialog is kept, because wrongly/); /* `at &&` is the guard: a null answer never reaches the exclusion. */ expect(src).toMatch(/if \(at &&/); }); }); /* * ── A NAVIGATION DRAWER IS NOT A MODAL ─────────────────────────────────────────────────────────── * * Every visibility test asks "is this thing on screen and in front", and a permanent left-hand * navigation passes all of them: visible, opaque, clickable, bigger than the size floor. Search * Console's nav carries a dialog role, so the reader kept answering "a dialog is open — 14 things to * click", handed the walk the fourteen nav links, and hid the report it was already standing on. * Three walks spent 130, 159 and 166 steps trying to close a drawer that covered nothing. * * The separating question is not visibility but whether it has TAKEN OVER. A modal makes the rest of * the page unreachable; a drawer leaves it exactly where it was. */ describe('a dialog must have taken the page over, not merely be on it', () => { const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/inspector.js'), 'utf8'); /* The two things a platform says when it means it beyond doubt. */ it('aria-modal and an open are taken at their word', () => { expect(src).toMatch(/const declared = d\.getAttribute\('aria-modal'\) === 'true'/); expect(src).toMatch(/\|\| \(d\.tagName === 'DIALOG' && d\.hasAttribute\('open'\)\)/); }); /* * A role alone proves nothing — it is the most-guessed-at attribute on the web — so anything not * declared has to prove it by the page behind actually being inert. */ it('anything else must prove it by the page behind being inert', () => { expect(src).toMatch(/if \(!declared\) \{/); expect(src).toMatch(/!!main\.closest\('\[aria-hidden="true"\],\[inert\]'\)/); expect(src).toMatch(/if \(!blocked\) return false;/); }); /* A page with no main landmark still gets an answer rather than a crash or a guess. */ it('and a page with no main landmark is still decided, not assumed', () => { expect(src).toMatch(/body > \[aria-hidden="true"\], body > \[inert\]/); }); /* The failure this cost, named, so the next person loosening it knows the price. */ it('the drawer that caused it is named in the reasoning', () => { expect(src).toMatch(/A NAVIGATION DRAWER IS NOT A MODAL/); expect(src).toMatch(/130, 159 and 166/); }); });