Ctrl K
ComunidadGuías…

__tests__/inspector-cap.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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('<div><button id="consent">OK, got it</button><button id="run">Run</button></div>');
    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('<div><button id="run">Run</button><button id="save">Save</button></div>');
    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 <dialog> 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/);
  });
});