Ctrl K
ComunidadGuías…

__tests__/roles.test.js

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

Archivo original ↗Preguntar a KiBot
/**
 * One harness, several specialists.
 *
 * Watching real runs, the failures were never "it could not think" — they were "it was doing the
 * wrong KIND of work": hunting for permalinks instead of recording people, inspecting page
 * furniture instead of reading posts. Narrowing what is within reach is the cheapest fix for that,
 * and it costs nothing at runtime.
 *
 * The guarantee worth pinning is the one a prompt cannot make: a scout that CANNOT post will not
 * post, however the conversation goes.
 */
import { describe, it, expect } from 'vitest';
import { ROLES, get, canonical, toolsFor, list, sites, useExternal } from '../src/roles.js';
import { TOOLS, looksLikeWrite, ownGround } from '../src/agent.js';

const names = (ts) => ts.map((t) => t.function.name);

describe('picking a role', () => {
  it('falls back to the general one rather than throwing', () => {
    expect(get('nonsense')).toBe(ROLES.general);
    expect(get(undefined)).toBe(ROLES.general);
    expect(get('FACEBOOK.SCOUT')).toBe(ROLES['facebook.scout']);
  });

  it('offers them to a UI without it knowing their names', () => {
    const l = list();
    expect(l.map((r) => r.name)).toEqual(expect.arrayContaining(
      ['general', 'facebook.scout', 'facebook.conversation', 'facebook.voice',
       'linkedin.scout', 'linkedin.conversation']));
    for (const r of l) expect(r.description).toBeTruthy();
  });

  it('carries each role\'s tools and source, so a builder UI can show and clone them', () => {
    for (const r of list()) {
      expect(r.source).toBe('builtin');
      expect(r.tools === null || Array.isArray(r.tools)).toBe(true);   // null = every tool
    }
    // The scout's real toolset comes through, not just a summary.
    const scout = list().find((r) => r.name === 'facebook.scout');
    expect(scout.tools).toEqual(expect.arrayContaining(['look', 'read', 'save_lead']));
  });
});

describe('Google Flow image-to-video role', () => {
  const flow = ROLES['googleflow.video'];
  it('has only the file and browser controls needed to submit and retrieve a video', () => {
    expect(flow.tools).toEqual(expect.arrayContaining(['open', 'look', 'upload_file', 'wait_for_ready', 'download_file', 'finish']));
  });
  it('requires Flow image-to-video and does not claim a text model can create video', () => {
    expect(flow.prompt).toMatch(/image-to-video/i);
    expect(flow.prompt).toMatch(/Google Flow/);
    expect(flow.prompt).toMatch(/asset id/i);
    expect(flow.prompt).toMatch(/do not claim that Google cannot generate video/i);
  });
});

describe('what each specialist can reach', () => {
  /*
   * THE ONE THAT MATTERS. A scout that can post is a scout that will eventually post, and the whole
   * point of separating this out is that a run which is only looking cannot write to somebody's
   * account by accident — whatever the model decides, and whatever anybody types at it.
   */
  it('a scout has no way to say anything at all', () => {
    const t = names(toolsFor('facebook.scout', TOOLS));
    expect(t).not.toContain('act');
    expect(t).toContain('sweep');
    expect(t).toContain('save_lead');
  });

  it('the voice role can neither post nor save leads — it is only reading a history', () => {
    const t = names(toolsFor('facebook.voice', TOOLS));
    expect(t).not.toContain('act');
    expect(t).not.toContain('save_lead');
    expect(t).toContain('save_my_writing');
  });

  /* The only one that writes, and it works from who is already waiting rather than going looking. */
  it('the conversation role can act but cannot go hunting for more', () => {
    const t = names(toolsFor('facebook.conversation', TOOLS));
    expect(t).toContain('act');
    expect(t).toContain('waiting_on');
    expect(t).not.toContain('sweep');
  });

  it('the general role keeps everything, so nothing was taken away from anyone', () => {
    expect(toolsFor('general', TOOLS)).toHaveLength(TOOLS.length);
  });

  /* A cookie wall does not care which specialist met it, and every role eventually meets one. */
  it.each(['facebook.scout', 'facebook.conversation', 'facebook.voice', 'linkedin.scout', 'linkedin.conversation', 'google.research', 'google.prospect', 'research.company', 'research.person'])('%s can still work a page by hand', (role) => {
    const t = names(toolsFor(role, TOOLS));
    for (const need of ['look', 'read', 'open', 'click', 'note', 'finish']) expect(t).toContain(need);
  });

  /*
   * A role naming a tool that no longer exists would silently narrow what it can do, and "the agent
   * mysteriously will not use a tool" is miserable to diagnose. Better to fall back to everything.
   */
  it('falls back to every tool rather than running a crippled specialist', () => {
    expect(toolsFor('typo', TOOLS)).toHaveLength(TOOLS.length);
  });

  it('every tool a role names actually exists', () => {
    const real = new Set(names(TOOLS));
    for (const [name, role] of Object.entries(ROLES)) {
      for (const t of role.tools || []) {
        expect(real.has(t), `role "${name}" names a tool that does not exist: ${t}`).toBe(true);
      }
    }
  });
});

describe('a set of roles per site', () => {
  it('keeps each site as its own craft', () => {
    expect(sites().sort()).toEqual(['facebook', 'google', 'googleflow', 'linkedin', 'upwork', 'useme']);
    expect(ROLES['facebook.scout'].site).toBe('facebook');
    expect(ROLES['linkedin.scout'].site).toBe('linkedin');
    expect(ROLES['google.research'].site).toBe('google');
  });

  /* Google is not a feed at all — it is a way of finding out. Its roles say so, and neither of
     them can post anywhere. */
  it('gives Google searching and reading, and no way to post', () => {
    for (const r of ['google.research', 'google.prospect']) {
      const t = names(toolsFor(r, TOOLS));
      expect(t).toContain('google');
      expect(t).toContain('dig');
      expect(t).not.toContain('act');
      expect(t).not.toContain('sweep');
    }
  });

  it('separates finding out from building a list, because they stop for different reasons', () => {
    expect(names(toolsFor('google.research', TOOLS))).not.toContain('save_lead');
    expect(names(toolsFor('google.prospect', TOOLS))).toContain('save_lead');
    expect(ROLES['google.research'].prompt).toMatch(/WHEN TO STOP/);
    expect(ROLES['google.prospect'].prompt).toMatch(/DO NOT SAVE A BUSINESS YOU HAVE NOT LOOKED AT/);
  });

  /* Inventing a plausible answer is worse than nothing, because somebody acts on it. */
  it('tells the researcher to admit what it did not find', () => {
    expect(ROLES['google.research'].prompt).toMatch(/Inventing a\s+plausible one is worse than nothing/);
  });

  /* Same word, different job: a comment in a Facebook group and a comment on somebody's
     professional record are not the same act, and the prompts have to say so. */
  it('gives each site its own instructions rather than one shared prompt', () => {
    expect(ROLES['facebook.scout'].prompt).not.toBe(ROLES['linkedin.scout'].prompt);
    expect(ROLES['linkedin.scout'].prompt).toMatch(/announce/);
    expect(ROLES['linkedin.conversation'].prompt).toMatch(/most despised thing on this platform/);
  });

  /* LinkedIn has its own adapter now, and its own vocabulary in the prompt — a shared one would
     have the model asking Facebook for a content search. */
  it('gives LinkedIn its own sweep and its own instructions', () => {
    expect(ROLES['linkedin.scout'].tools).toContain('sweep');
    expect(ROLES['linkedin.scout'].prompt).toMatch(/site: "linkedin"/);
  });

  /*
   * RESEARCH CROSSES SITES, which is the whole point of it, so it belongs to none — and it must not
   * be filed next to General as though it were a fallback.
   */
  it('keeps research as its own group rather than a site', () => {
    expect(ROLES['research.company'].site).toBeNull();
    const g = list().find((r) => r.name === 'research.company').group;
    expect(g).toBe('Research');
  });

  it('gives research every site’s reading tools and no way to post', () => {
    const t = names(toolsFor('research.company', TOOLS));
    for (const need of ['google', 'dig', 'sweep', 'use_profile', 'list_profiles']) expect(t).toContain(need);
    expect(t).not.toContain('act');
  });

  /* One site at a time: switching site means switching browser context, and hopping back and forth
     spends the run reopening browsers instead of reading. */
  it('tells it to finish one site before moving to the next', () => {
    expect(ROLES['research.company'].prompt).toMatch(/ONE AT A TIME/);
    expect(ROLES['research.company'].prompt).toMatch(/A gap named is useful/);
  });

  /* Names repeat, and confidently reporting the wrong person is the worst outcome here. */
  it('tells the person researcher to prove it is the same person', () => {
    expect(ROLES['research.person'].prompt).toMatch(/BE CAREFUL WITH IDENTITY/);
    expect(ROLES['research.person'].prompt).toMatch(/nobody's business/);
  });

  it('groups them for a picker without it knowing any site names', () => {
    const groups = [...new Set(list().map((r) => r.group))];
    expect(groups).toEqual(expect.arrayContaining(['Facebook', 'Linkedin', 'Google', 'Research', 'Anything']));
  });

  /* A conversation started before roles were grouped must still reopen as what it was — silently
     turning an old scout run into a general one would rewrite the record. */
  it.each([['scout', 'facebook.scout'], ['voice', 'facebook.voice'], ['conversation', 'facebook.conversation']])
    ('still resolves the old name %s', (old, now) => {
      expect(get(old)).toBe(ROLES[now]);
      expect(canonical(old)).toBe(now);
    });

  it('stores a name that will resolve later, not whatever was typed', () => {
    expect(canonical('FaceBook.Scout')).toBe('facebook.scout');
    expect(canonical('nonsense')).toBe('general');
  });
});

describe('the research gates (products) and client gates (freelance)', () => {
  /* The master dispatches these EXACT names; if a rename drops one, GB silently falls back to the
     general role and the gate loses its specialism. Pin the names as the integration contract. */
  const MARKET = ['research.reddit', 'research.linkedin', 'research.web'];
  const CLIENT = ['client.linkedin', 'client.web'];

  it.each([...MARKET, ...CLIENT])('%s exists as a real role (not a general fallback)', (name) => {
    expect(ROLES[name], `the master dispatches "${name}" — it must be a real role`).toBeTruthy();
    expect(get(name)).toBe(ROLES[name]);
  });

  it('every gate role is READ-ONLY — a research run can never post, connect or message', () => {
    for (const name of [...MARKET, ...CLIENT]) {
      const t = names(toolsFor(name, TOOLS));
      expect(t, name).not.toContain('act');
      for (const need of ['look', 'read', 'open', 'note', 'finish']) expect(t, name).toContain(need);
    }
  });

  it('Reddit is worked without a login, through the open web (google + dig, no sweep adapter)', () => {
    expect(ROLES['research.reddit'].site).toBeNull();
    const t = names(toolsFor('research.reddit', TOOLS));
    expect(t).toContain('google');
    expect(t).toContain('dig');
    expect(ROLES['research.reddit'].prompt).toMatch(/old\.reddit\.com/);
    expect(ROLES['research.reddit'].prompt).toMatch(/never post/i);
  });

  it('the LinkedIn gates use the LinkedIn adapter (sweep) on the logged-in session', () => {
    for (const name of ['research.linkedin', 'client.linkedin']) {
      expect(ROLES[name].site).toBe('linkedin');
      expect(names(toolsFor(name, TOOLS))).toContain('sweep');
      expect(ROLES[name].prompt).toMatch(/site: "linkedin"/);
    }
  });

  /*
   * Found live: the LinkedIn login had expired, the audience pass landed on the sign-in page, hopped
   * to the Facebook login (still signed out of LinkedIn), swept six searches to "0 posts" and would
   * have filed that as the finding. The rule is a fixed sentence the master recognises and HOLDS the
   * pass on, rather than a report of nothing it would judge.
   */
  it('an audience pass that meets the sign-in page says SIGNED OUT in a fixed form instead of switching login', () => {
    expect(ROLES['research.linkedin'].prompt).toMatch(/SIGNED OUT: LinkedIn — sign in on the linkedin\nprofile in Ghost Browser and rerun this pass\./);
    expect(ROLES['research.linkedin'].prompt).toMatch(/Do NOT switch\nto another login/);
    expect(ROLES['research.reddit'].prompt).toMatch(/SIGNED OUT: Reddit — sign in on the reddit profile in Ghost Browser and rerun\nthis pass\./);
  });

  it('the web gates search and read (google + dig), and never sweep a feed', () => {
    for (const name of ['research.web', 'client.web']) {
      expect(ROLES[name].site).toBe('google');
      const t = names(toolsFor(name, TOOLS));
      expect(t).toContain('google');
      expect(t).toContain('dig');
      expect(t).not.toContain('sweep');
    }
  });

  it('files the two gates as their own groups in a picker', () => {
    const byName = Object.fromEntries(list().map((r) => [r.name, r.group]));
    for (const n of MARKET) expect(byName[n]).toBe('Research');
    for (const n of CLIENT) expect(byName[n]).toBe('Client research');
  });

  it('client.web hunts for red flags — the point is protecting a scarce bid', () => {
    const flat = ROLES['client.web'].prompt.replace(/\s+/g, ' ');
    expect(flat).toContain('RED FLAGS');
    expect(flat).toContain('WALK AWAY');
  });

  it('introduces no new site (Reddit needs no adapter)', () => {
    expect(sites().sort()).toEqual(['facebook', 'google', 'googleflow', 'linkedin', 'upwork', 'useme']);
  });
});

describe('the reach roles — reading OUR OWN numbers for the Pulse distribution hub', () => {
  const REACH = ['reach.linkedin', 'reach.x', 'reach.reddit', 'reach.producthunt', 'reach.facebook', 'reach.youtube'];

  it.each(REACH)('%s exists, is read-only, and files structured numbers via save_reach', (name) => {
    expect(ROLES[name], name).toBeTruthy();
    const t = names(toolsFor(name, TOOLS));
    expect(t, name).not.toContain('act');           // a collection run can NEVER post
    expect(t, name).toContain('save_reach');        // numbers arrive structured, never parsed from prose
    for (const need of ['look', 'read', 'open', 'note', 'finish']) expect(t, name).toContain(need);
  });

  it('every reach prompt carries the one discipline that protects the verdicts: never estimate', () => {
    for (const name of REACH) {
      expect(ROLES[name].prompt).toMatch(/never (estimate|post|vote|upload)/i);
      const flat = ROLES[name].prompt.replace(/\s+/g, ' ');
      expect(flat).toMatch(/ONLY what/i);
      expect(flat).toMatch(/shown|shows|display/i);
    }
  });

  it('reach is its own group, and reads OUR numbers — distinct from research (the market\'s)', () => {
    const byName = Object.fromEntries(list().map((r) => [r.name, r.group]));
    for (const n of REACH) expect(byName[n]).toBe('Reach');
    expect(ROLES['reach.linkedin'].site).toBe('linkedin');
    expect(ROLES['reach.facebook'].site).toBe('facebook');
    expect(ROLES['reach.reddit'].site).toBeNull();  // no adapter needed — hands + the logged-in session
  });
});

describe('the posting roles — the only new writers, and drilled for it', () => {
  it.each(['post.reddit', 'post.linkedin'])('%s can act (through the gate) and carries the one-post discipline', (name) => {
    const t = names(toolsFor(name, TOOLS));
    expect(t).toContain('act');                      // a writer — but only through the approval gate
    expect(t).not.toContain('sweep');                // delivery, not hunting
    expect(t).not.toContain('save_lead');
    for (const need of ['look', 'read', 'open', 'note', 'finish']) expect(t).toContain(need);
    const flat = ROLES[name].prompt.replace(/\s+/g, ' ');
    expect(flat).toMatch(/ONE post/i);               // never sprays
    expect(flat).toMatch(/EXACTLY as given/);        // delivery, not authorship
    expect(flat).toMatch(/act gate/i);               // nothing visible without approval
  });
  it('the reddit poster reads the sub\'s rules before anything, and refuses rather than breaks them', () => {
    expect(ROLES['post.reddit'].prompt).toMatch(/rules in its sidebar/);
    expect(ROLES['post.reddit'].prompt).toMatch(/do NOT post/);
  });
});

describe('save_opportunity — the hunt files evidence, it does not narrate it', () => {
  it('the research roles carry the opportunity tool; reddit no longer misuses save_lead for market evidence', () => {
    expect(ROLES['research.reddit'].tools).toContain('save_opportunity');
    expect(ROLES['research.reddit'].tools).not.toContain('save_lead');   // a thread is evidence, not an outreach target
    expect(ROLES['research.linkedin'].tools).toEqual(expect.arrayContaining(['save_lead', 'save_opportunity']));
    expect(ROLES['research.web'].tools).toContain('save_opportunity');
  });
  it('the outreach scouts keep save_lead and never gain the opportunity tool', () => {
    for (const r of ['facebook.scout', 'linkedin.scout']) {
      if (!ROLES[r]) continue;
      expect(ROLES[r].tools).toContain('save_lead');
      expect(ROLES[r].tools).not.toContain('save_opportunity');
    }
  });
  it('the reddit prompt demands the link and the page\'s own date, and refuses an estimated one', () => {
    const p = ROLES['research.reddit'].prompt;
    expect(p).toMatch(/save_opportunity/);
    expect(p).toMatch(/THE DATE THE PAGE\s*\n?SHOWS/);
    expect(p).toMatch(/never estimate one/);
    expect(p).toMatch(/no thread link behind it does not get saved/);
  });
  it('the research roles stay READ-ONLY — evidence gathering never gains hands that post', () => {
    for (const r of ['research.reddit', 'research.linkedin', 'research.web']) {
      expect(ROLES[r].tools).not.toContain('act');
    }
  });
  /*
   * With google() dead, a pass "remembered" Reddit thread addresses and dug them; Reddit redirected the
   * invented ids to unrelated threads, which were then quoted as evidence. Every role that digs is told
   * the rule in the same words, and the browser refuses the redirected page (sites/google.js) as backstop.
   */
  it('the dig tool itself carries the rule, so every role that digs hears it; the research roles repeat it in their craft', () => {
    const dig = TOOLS.find((t) => t.function.name === 'dig');
    expect(dig.function.description).toMatch(/Only open links you have actually SEEN/);
    expect(dig.function.description).toMatch(/Never an address composed from memory/);
    expect(dig.function.parameters.properties.url.description).toMatch(/never typed from memory/);
    for (const r of ['research.reddit', 'research.web', 'research.reviews', 'research.market']) {
      expect(ROLES[r].prompt, `${r} must carry the rule`).toMatch(/only\s+(open|dig)\s+(links|addresses)[^.]*(seen|listed)/i);
      expect(ROLES[r].prompt, `${r} must say where the rule bites`).toMatch(/from memory/i);
    }
    expect(ROLES['research.reddit'].prompt).toMatch(/Reddit does not 404 a wrong\nid, it silently redirects/);
  });
});

describe('the discovery roles — hunting where people already pay', () => {
  // A hunt routed to research.web got a DISCOVERY goal and a VALIDATION prompt ("you find the
  // competitors and prices FOR A PRODUCT IDEA") — two instructions that contradict each other when
  // no idea exists yet. These are their own roles so the craft matches the job.
  it('both exist, are read-only, and file through the structured tool', () => {
    for (const r of ['research.reviews', 'research.market']) {
      expect(ROLES[r]).toBeTruthy();
      expect(ROLES[r].tools).toContain('save_opportunity');
      expect(ROLES[r].tools).not.toContain('act');       // discovery never writes
      expect(ROLES[r].tools).toContain('google');
      expect(ROLES[r].tools).toContain('dig');
    }
  });
  it('research.web keeps its VALIDATION job — the roles are not duplicates', () => {
    expect(ROLES['research.web'].prompt).toMatch(/COMPETITORS AND THE REAL PRICES for a product idea/);
    expect(ROLES['research.reviews'].prompt).toMatch(/WHAT PEOPLE WHO ALREADY PAY SAY IS MISSING/);
  });
  it('reviews: knows the sites, goes to the ONE AND TWO star reviews, and prices the incumbent', () => {
    const p = ROLES['research.reviews'].prompt;
    for (const site of ['G2', 'Capterra', 'Trustpilot', 'GetApp']) expect(p).toContain(site);
    expect(p).toMatch(/ONE AND TWO STAR reviews/);
    expect(p).toMatch(/Five-star reviews teach\s*\n?you nothing/);
    expect(p).toMatch(/PRICE from the vendor's own pricing page/);
  });
  it('reviews: we may READ a marketplace we can never LIST in — the distinction is stated', () => {
    const p = ROLES['research.reviews'].prompt;
    expect(p).toMatch(/cannot LIST a product in someone else's store, but their\s*\n?reviews are public/);
    expect(p).toMatch(/serve those same\s*\n?people with our own standalone tool/);
  });
  it('reviews: a discontinued tool is named as the strongest signal there is', () => {
    expect(ROLES['research.reviews'].prompt).toMatch(/DISCONTINUED, sunset, acquired-and-gutted/);
    expect(ROLES['research.reviews'].prompt).toMatch(/budget and nowhere to spend it/);
  });
  it('market: reads boards for a RECURRING task and brings back the budget band', () => {
    const p = ROLES['research.market'].prompt;
    for (const board of ['Upwork', 'Fiverr', 'PeoplePerHour', 'Useme']) expect(p).toContain(board);
    expect(p).toMatch(/never bid, apply or message/);
    expect(p).toMatch(/SAME task described by many different clients/);
    expect(p).toMatch(/budget that recurs in a band/);
    expect(p).toMatch(/if a task appears twice, that is not a market/);
  });
  it('both refuse to manufacture a finding', () => {
    expect(ROLES['research.reviews'].prompt).toMatch(/Do not manufacture a\s*\n?gap because you were sent to look for one/);
    expect(ROLES['research.market'].prompt).toMatch(/Say so and finish rather than padding/);
  });
});

describe('qa.web — testing our own app is its own craft', () => {
  // QA was dispatched with NO role, so it got `general`: every tool, no craft. It opened a freshly
  // built app, looked, got "0 things to click", and repeated that for 241 steps until the step limit
  // — never reporting on one criterion. The owner then opened the same URL and saw a landing page
  // and a login form. THE APP WAS FINE. QA had looked before the SPA rendered, had no instruction to
  // wait, and no required verdict shape — so the parser read nothing and scored four blind FAILs,
  // which sent a fixer to debug the backend for 1.37M tokens and zero files changed.
  const p = () => ROLES['qa.web'].prompt;

  it('can drive a page, and acts ONLY on the app it was sent to test', () => {
    expect(ROLES['qa.web']).toBeTruthy();
    for (const t of ['look', 'read', 'open', 'click', 'type', 'scroll', 'finish']) {
      expect(ROLES['qa.web'].tools).toContain(t);   // it must be able to USE the app
    }
    /*
     * `act` WAS excluded here, and that was right until it wasn't. QA is sent to an app WE built,
     * where pressing the buttons is the whole job — and the write-guard refused "Connect Etsy shop"
     * because `connect` means something else entirely on LinkedIn. The proposal then waited for a
     * human nobody had asked to watch, and the run held the browser until the watchdog.
     *
     * So QA carries `act` now, and the safety moved from the TOOL to the ORIGIN: it is auto-approved
     * only on the one app it was sent to, and gated everywhere else — including on the OAuth screen
     * that same button hands it to. See ownGround() in agent.js.
     */
    expect(ROLES['qa.web'].tools).toContain('act');
    expect(ROLES['qa.web'].trustsOwnOrigin).toBe(true);
    // Still not a lead-gatherer or a demand-miner; that craft belongs to other roles.
    for (const t of ['save_lead', 'sweep', 'save_opportunity']) {
      expect(ROLES['qa.web'].tools).not.toContain(t);
    }
  });
  it('knows a blank first look means WAIT, not broken — the exact mistake that cost the run', () => {
    expect(p()).toMatch(/A BLANK FIRST LOOK IS NORMAL/);
    expect(p()).toMatch(/single-page apps/);
    expect(p()).toMatch(/look AGAIN/);
    expect(p()).toMatch(/read\(\) the page/);
    expect(p()).toMatch(/two looks in a row are identical/);
  });
  it('tests as a stranger, because that is who signs up', () => {
    expect(p()).toMatch(/TEST AS A STRANGER/);
    expect(p()).toMatch(/Never reuse a stored login/);
  });
  it('measures rather than repairs, one criterion at a time', () => {
    expect(p()).toMatch(/ONE CRITERION AT A TIME/);
    expect(p()).toMatch(/You are measuring, not\s*\n?repairing/);
  });
  it('reports in the exact shape the pipeline parses, and says NOT TESTED rather than guessing', () => {
    expect(p()).toMatch(/FINISH WITH THIS EXACT SHAPE/);
    expect(p()).toMatch(/— PASS/);
    expect(p()).toMatch(/— FAIL: <the exact symptom/);
    expect(p()).toMatch(/NOT TESTED: <why>/);
    expect(p()).toMatch(/an untested criterion is not a failed one/);
  });
});

describe('diagnostics — QA can finally see what the browser sees', () => {
  // The app under test bounced from the landing page to /login over and over: the console said
  // `GET /api/… 401` and the address bar looped, but GB listened to NONE of Playwright's console,
  // pageerror, response or framenavigated events. QA had only `look`, caught the page mid-navigation,
  // and reported "0 things to click" fifty times — unable to describe a bug it could not perceive.
  it('is QA\'s tool and no one else\'s — these buffers can hold tokens on a signed-in site', () => {
    expect(ROLES['qa.web'].tools).toContain('diagnostics');
    const others = Object.entries(ROLES)
      .filter(([k, r]) => k !== 'qa.web' && Array.isArray(r.tools) && r.tools.includes('diagnostics'));
    expect(others.map(([k]) => k)).toEqual([]);
  });
  it('the general role does not silently grant it — null tools means every tool', () => {
    // `general` has tools:null (everything), which is exactly why QA must never run roleless again.
    expect(ROLES.general.tools).toBe(null);
    expect(ROLES['qa.web'].tools).not.toBe(null);
  });
  it('QA is told to ask the browser before reporting a failure', () => {
    const p = ROLES['qa.web'].prompt;
    expect(p).toMatch(/ASK THE BROWSER/);
    expect(p).toMatch(/always before you report a criterion as failed/);
    expect(p).toMatch(/REDIRECT LOOP is the case to watch for/);
    expect(p).toMatch(/retrying is pointless/);
  });
});

/**
 * OUR OWN GROUND — the gate that protects nobody, and deadlocked QA instead.
 *
 * Measured, job j-mte5i8ic-d4fao: QA was testing an app we had just built, found the one button the
 * acceptance criteria are about — "Connect Etsy shop" — and was refused, because `connect` is in
 * WRITE_WORDS for LinkedIn where it messages a real person. The click became an act proposal, the
 * proposal waited for a human nobody had asked to watch, and the run held the only browser session
 * until the three-hour watchdog. QA cannot test an app by refusing to press its buttons.
 */
describe('own-origin trust — QA may press its own app\'s buttons, and nobody else\'s', () => {
  const APP = 'https://prod-profit-tracker.example';
  const CONNECT = { text: 'Connect Etsy shop' };

  it('"Connect" is still a write everywhere — the heuristic is right, it just cannot see whose site it is on', () => {
    expect(looksLikeWrite(CONNECT)).toBe(true);
    expect(looksLikeWrite({ text: 'Connect' })).toBe(true);
  });

  it('covers the app under test, and nothing else on the internet', () => {
    const g = ownGround(APP);
    expect(g.covers(APP)).toBe(true);
    expect(g.covers(APP + '/dashboard?x=1')).toBe(true);
    // The very button above hands QA to Etsy's real OAuth screen. Different origin, gate closed.
    expect(g.covers('https://www.etsy.com/oauth/connect')).toBe(false);
    expect(g.covers('https://prod-profit-tracker.example.evil.com')).toBe(false);
    expect(g.covers('http://prod-profit-tracker.example')).toBe(false);   // scheme is part of it
  });

  it('a role that never asked for it gets nothing, whatever is passed', () => {
    // The safety is that trust is declared in the registry, not supplied by the caller.
    expect(get('qa.web').trustsOwnOrigin).toBe(true);
    for (const r of ['general', 'facebook.scout', 'linkedin.scout', 'research.web']) {
      expect(get(r).trustsOwnOrigin, r).toBeFalsy();
    }
  });

  it('no origin means no trust — an absent or unparseable one must never open the gate', () => {
    for (const bad of [null, undefined, '', 'not a url', 'javascript:alert(1)', 'about:blank', 'data:text/html,x']) {
      const g = ownGround(bad);
      expect(g.covers(APP), String(bad)).toBe(false);
      expect(g.covers(bad), String(bad)).toBe(false);
    }
  });

  it('QA carries act, or it could not press anything even where it is allowed to', () => {
    expect(get('qa.web').tools).toContain('act');
  });
});

describe('Discovery / SEO roles — fully browser-native (no API, no service account)', () => {
  it('seo.keywords is read-only and files structured keywords via save_keywords', () => {
    expect(ROLES['seo.keywords']).toBeTruthy();
    const t = names(toolsFor('seo.keywords', TOOLS));
    expect(t).not.toContain('act');                 // research never acts
    expect(t).toContain('save_keywords');
    for (const need of ['look', 'read', 'open', 'note', 'finish']) expect(t).toContain(need);
    expect(ROLES['seo.keywords'].prompt).toMatch(/never invent|ONLY what a tool actually shows/i);
  });
  it('gsc.connect adds the property + reads the verification token — no service account, no API', () => {
    const t = names(toolsFor('gsc.connect', TOOLS));
    expect(t).toContain('act');
    expect(t).toContain('save_gsc_token');
    const p = ROLES['gsc.connect'].prompt;
    expect(p).toMatch(/URL prefix/);
    expect(p).toMatch(/HTML tag/);
    expect(p).toMatch(/do NOT click Verify yet/i);   // verify is a later step, after the token is planted
    expect(p).toMatch(/CAPTCHA|phone/i);
    expect(p).not.toMatch(/service account/i);       // the API path is gone
  });
  it('gsc.verify clicks Verify; reach.search READS the Performance page (read-only) and files days + queries', () => {
    expect(names(toolsFor('gsc.verify', TOOLS))).toContain('act');
    expect(ROLES['gsc.verify'].prompt).toMatch(/Verify/);
    const s = names(toolsFor('reach.search', TOOLS));
    expect(s).not.toContain('act');                  // reading only
    expect(s).toContain('save_reach');
    expect(s).toContain('save_search');
    /* Case-insensitive: the role shouts it in the opening line. What matters is that this reader
       is pointed at the performance report and nothing else. */
    expect(ROLES['reach.search'].prompt).toMatch(/performance/i);
    expect(ROLES['reach.search'].prompt).toMatch(/never (add|estimate)|only what the page shows/i);
  });
  it('the Discovery roles live on the existing google site (sites() unchanged) and are grouped sensibly', () => {
    for (const n of ['seo.keywords', 'gsc.connect', 'gsc.verify', 'reach.search']) expect(ROLES[n].site).toBe('google');
    expect(sites()).toContain('google');
    const byName = Object.fromEntries(list().map((r) => [r.name, r.group]));
    expect(byName['seo.keywords']).toBe('SEO');
    expect(byName['gsc.connect']).toBe('SEO');
    expect(byName['gsc.verify']).toBe('SEO');
    expect(byName['reach.search']).toBe('Reach');
  });
});

/*
 * THE MAPS SPECIALIST. A generic "find businesses on Maps" walk searched one ambiguous word, took
 * what Maps offered, and saved a parking garage for a car-garage search. Working Maps is a skill,
 * and this role is that skill written down — so these pin the parts a thin prompt always loses.
 */
describe('the Google Maps specialist', () => {
  const r = get('google.maps');

  it('exists, on google, and reaches save_place rather than save_lead', () => {
    expect(r).toBeTruthy();
    expect(r.site).toBe('google');
    expect(r.tools).toContain('save_place');
    expect(r.tools).not.toContain('save_lead');
  });

  it('decides which business the word means before it searches', () => {
    expect(r.prompt).toMatch(/parkeergarage/i);
    expect(r.prompt).toMatch(/autobedrijf|garagebedrijf/i);
    expect(r.prompt).toMatch(/local language/i);
  });

  it('checks the category on every card, which is what a wrong result is caught by', () => {
    expect(r.prompt).toMatch(/CATEGORY printed under the name/i);
    expect(r.prompt).toMatch(/do not save it/i);
  });

  it('reads the reviews, which is the part everyone skips', () => {
    expect(r.prompt).toMatch(/OPEN THE REVIEWS/i);
    expect(r.prompt).toMatch(/lowest-rated/i);
    expect(r.prompt).toMatch(/OWNER REPLIED/i);
    expect(r.prompt).toMatch(/how many ratings/i);
  });

  it('is read-only, like every research role', () => {
    expect(r.prompt).toMatch(/never sign in/i);
    expect(r.prompt).toMatch(/Read-only|read-only/);
    expect(r.tools).not.toContain('act');
  });
});

/*
 * THE SECOND DOOR, NAMED.
 *
 * A picture walk found Gemini unwilling — a failed generation and a sign-in control — went to AI
 * Studio on its own initiative, came back, and finished the job. I called that drift. Carla pushed
 * back: the goal is an image from the owner's own Google account, and AI Studio is the same account
 * and the same capability through another door. She was right, and the outcome proved it.
 *
 * The answer to a good improvisation is to SANCTION it, not forbid it — and to bound it. Wandering
 * costs pages and steps from a walk that has a budget, so two named addresses is a fallback and
 * three is a search. And the source is reported, because a picture whose origin nobody recorded is
 * one nobody can reproduce when it matters.
 */
describe('the image role has one fallback, named and bounded', () => {
  const roles = require('../src/roles');
  const prompt = roles.ROLES['gemini.image'].prompt;

  /*
   * THE ORDER WAS BACKWARDS AND IT COST EVERY RUN.
   *
   * Gemini was the front door and AI Studio the fallback. For this account it is the other way round:
   * the Gemini session is signed OUT, and a signed-out Gemini does not refuse — it accepts the
   * reference image, accepts the prompt, and sits on "Uploading file: 50%" for ever. So each walk
   * spent its minutes on a chat that was never going to answer before it was allowed to try the door
   * that works. AI Studio is signed in, on PRO, with the image models on it.
   */
  it('AI Studio is the FIRST door, not the fallback', () => {
    expect(prompt).toMatch(/THE FIRST DOOR — GOOGLE AI STUDIO/);
    expect(prompt).toMatch(/THE SECOND DOOR — GEMINI/);
    expect(prompt.indexOf('aistudio.google.com')).toBeLessThan(prompt.indexOf('gemini.google.com'));
  });

  /* Two addresses is a fallback; a third would be a search, and a search spends the whole walk. */
  it('and bounds it to exactly two, once each', () => {
    expect(prompt).toMatch(/Try AI Studio ONCE and Gemini ONCE/);
    expect(prompt).toMatch(/those two addresses are the only ones/);
    expect(prompt).toMatch(/never go hunting for a third/);
  });

  it('and still refuses to invent an image when neither will do it', () => {
    expect(prompt).toMatch(/NOTE exactly what each of them said and finish/);
    expect(prompt).toMatch(/Never fake an image/);
  });

  /* Asking a text model for a picture looks exactly like a refusal and is not one. */
  it('it checks the model is an image model before blaming the tool', () => {
    expect(prompt).toMatch(/It must be an IMAGE model/);
    expect(prompt).toMatch(/Asking a text model for a picture/);
  });

  it('and records which door produced the picture', () => {
    expect(prompt).toMatch(/SAY WHICH DOOR IT CAME THROUGH/);
  });
});

/*
 * ── SEARCH CONSOLE, AS A SPECIALITY RATHER THAN ONE TAB ──────────────────────────────────────────
 *
 * There was one reader and it read Performance. On a property verified yesterday Performance says
 * nought clicks, one impression, average position four — every number it has, and not one of them
 * worth a decision. Meanwhile the same console was holding an unread message from Google, a
 * page-indexing report still processing, and the table of reasons pages are refused: all actionable
 * on day one, all of it changing while the numbers cannot.
 *
 * So the console gets three walks instead of one, split by what they are ALLOWED to do rather than by
 * which screen they visit. Two look. One asks Google for something, and only that one can.
 */
describe('the Search Console specialists', () => {
  const roles = require('../src/roles');

  it('there are three, and only one of them can act', () => {
    for (const k of ['gsc.audit', 'gsc.inspect']) {
      expect(roles.ROLES[k]).toBeTruthy();
      /* A role that cannot press the button never presses it. This is the guard, not the prompt. */
      expect(roles.ROLES[k].tools).not.toContain('act');
    }
    expect(roles.ROLES['gsc.submit'].tools).toContain('act');
  });

  it('all three file what they read through the one door', () => {
    for (const k of ['gsc.audit', 'gsc.inspect', 'gsc.submit']) {
      expect(roles.ROLES[k].tools).toContain('save_gsc_health');
    }
  });

  /* The audit is the one that answers "is anything getting in at all". */
  it('the audit walks every corner that says something on a young property', () => {
    const p = roles.ROLES['gsc.audit'].prompt;
    for (const corner of ['MESSAGES', 'MANUAL ACTIONS', 'PAGE INDEXING', 'SITEMAPS', 'CORE WEB VITALS']) {
      expect(p).toContain(corner);
    }
    /* The refusal reasons are the point of the whole walk. */
    expect(p).toMatch(/EVERY row under "Why pages aren't indexed"/);
    /* And it stays off Performance: two sources for one truth is how numbers stop being trusted. */
    expect(p).toMatch(/Do not read Performance here/);
  });

  it('a console in another language is matched by meaning, because this one is Dutch', () => {
    expect(roles.ROLES['gsc.audit'].prompt).toMatch(/match by MEANING/i);
    expect(roles.ROLES['gsc.audit'].prompt).toMatch(/Berichten/);
    expect(roles.ROLES['gsc.audit'].prompt).toMatch(/Pagina's/);
  });

  /* "No manual action" is the finding that lets every other number be believed. */
  it('a clean verdict is recorded, not skipped as uninteresting', () => {
    expect(roles.ROLES['gsc.audit'].prompt).toMatch(/Record these EVEN WHEN CLEAN/);
  });

  it('the inspector answers for named pages and refuses to request anything', () => {
    const p = roles.ROLES['gsc.inspect'].prompt;
    expect(p).toMatch(/NEVER press "Request indexing"/);
    expect(p).toMatch(/A page that IS indexed is as much a finding as one that is not/);
  });

  /*
   * The submitter is the only walk here that reaches Google, and its whole discipline is not wasting
   * a quota: check first, submit only what is missing, stop at the list.
   */
  it('the submitter checks before it spends a quota, and stops at the list', () => {
    const p = roles.ROLES['gsc.submit'].prompt;
    expect(p).toMatch(/If it is ALREADY on Google, record that .* and DO NOT request it/);
    expect(p).toMatch(/STOP at the number of URLs the goal names/);
    expect(p).toMatch(/THROUGH THE ACT GATE, every time/);
    /* Removals and ownership are destructive and are nobody's automation. */
    expect(p).toMatch(/never use Removals/);
  });

  it('and none of them wanders into signing anything in or changing settings', () => {
    for (const k of ['gsc.audit', 'gsc.inspect', 'gsc.submit']) {
      expect(roles.ROLES[k].site).toBe('google');
      expect(roles.ROLES[k].group).toBe('Reach');
    }
  });
});

/*
 * ONE QUESTION, ITS WHOLE FAMILY.
 *
 * The keyword walk researches an AUDIENCE and returns a plan: fifty categories, one per line. The
 * writer takes a line and writes a page from it, so the page answers the question in the PLAN's
 * shorthand rather than in the words people type. "best ai app builder platform" is a category;
 * "which ai app builder lets me export the code" is what somebody asks, and they are not the same
 * page. This walk closes that gap for ONE term, just before it is written.
 */
describe('expanding one search term', () => {
  const roles = require('../src/roles');
  const r = roles.ROLES['seo.expand'];

  it('exists, and can only file what it read', () => {
    expect(r).toBeTruthy();
    expect(r.tools).toContain('save_keywords');
    /* Read-only: no act, so it cannot create a campaign or save anything in Google's own tools. */
    expect(r.tools).not.toContain('act');
  });

  it('reads the three places people actually reveal their words', () => {
    expect(r.prompt).toMatch(/AUTOCOMPLETE/);
    expect(r.prompt).toMatch(/PEOPLE ALSO ASK/);
    expect(r.prompt).toMatch(/KEYWORD PLANNER/);
  });

  /* Planner is an advertiser's average of everybody; the other two are the words themselves. */
  it('and Planner is the one it is allowed to give up on', () => {
    expect(r.prompt).toMatch(/IF PLANNER WILL NOT OPEN/);
    expect(r.prompt).toMatch(/never worth a fight/);
  });

  it('it stays on ONE question rather than drifting into the category', () => {
    expect(r.prompt).toMatch(/File the term's FAMILY, not the whole category/);
    expect(r.prompt).toMatch(/a page that tries to answer twenty different questions answers none of them/);
  });

  /* This feeds what gets written, so an invented figure becomes an invented page. */
  it('and nothing it did not see on a screen may be filed', () => {
    expect(r.prompt).toMatch(/NEVER invent a phrase or a number/);
    expect(r.prompt).toMatch(/an invented figure becomes an invented page/);
  });
});

/*
 * ── THE ROLE ARRIVES, IT DOES NOT EXPLORE ────────────────────────────────────────────────────────
 *
 * This role used to say "open Search Console for that property and its Performance report, last 28
 * days, with Total clicks and Total impressions turned on, then open the QUERIES tab". Every clause
 * is a click, on a console that renders in the account's own language — so a walk looking for
 * "Performance" clicked the Google apps grid, twice, and took 166 steps to read one number.
 *
 * All of it is in the address: the range, the metrics, the breakdown. The role's job is to say so
 * and to forbid the hunting, because the hunting is what costs the steps.
 */
describe('the search console reader is told to arrive, not to explore', () => {
  const src = require('node:fs').readFileSync(require('node:path').join(__dirname, '../src/roles.js'), 'utf8');
  const role = src.slice(src.indexOf("'reach.search': {"), src.indexOf("'gsc.audit': {"));

  it('the addresses are opened, never navigated to', () => {
    expect(role).toMatch(/THE GOAL GIVES YOU THE EXACT ADDRESSES\. OPEN THEM\. DO NOT NAVIGATE\./);
    expect(role).toMatch(/Never click a tab, a menu item, a date picker or a metric toggle/);
  });

  /* The failure named, so the next person widening this knows what it cost. */
  it('and it says why, in the account\'s own language', () => {
    expect(role).toMatch(/"Prestaties", "Rendimiento" or "Leistung"/);
    expect(role).toMatch(/166 steps/);
  });

  it('a drawer is escaped rather than clicked out of', () => {
    expect(role).toMatch(/press Escape and open the address again/);
  });

  /* A budget in words: still hunting after ten steps is a finding, not a reason to carry on. */
  it('and it stops hunting instead of continuing for ever', () => {
    expect(role).toMatch(/If you are still looking for the report after ten/);
    expect(role).toMatch(/saying which is far more useful than continuing to hunt/);
  });

  /* The read-only contract and the honest-empty rule both survive the rewrite. */
  it('while it still never changes anything, and an empty stays honest', () => {
    expect(role).toMatch(/never add, remove, verify or change anything/);
    expect(role).toMatch(/an honest empty is a real result; a guessed number is poison/);
  });
});

/*
 * ── THE AUDIT ARRIVES AT SIX TABS INSTEAD OF LOOKING FOR THEM ────────────────────────────────────
 *
 * The role told the walk to "go through the console" — messages, manual actions, page indexing,
 * sitemaps, vitals — and listed the Dutch names so it could recognise them. That is prompt
 * engineering against a moving target: the labels change with whatever language the account uses.
 * Measured: 21 opens, 15 reads, 15 looks, stopped at 142 steps, and the sitemaps tab never reached.
 *
 * Every one of those tabs has an address. The performance collection had the identical problem and
 * lost it the moment it was handed addresses instead of directions — 166 steps became 18.
 */
describe('the audit is given the tabs rather than sent to find them', () => {
  const role = ROLES['gsc.audit'].prompt;

  it('the addresses are opened, not navigated to', () => {
    expect(role).toMatch(/THE GOAL GIVES YOU AN ADDRESS FOR EVERY TAB\. OPEN THEM IN TURN\. DO NOT NAVIGATE\./);
    expect(role).toMatch(/there is no menu to find and no bell to click/);
  });

  /* Counts are a table; retyping them out of prose is the step that can be silently wrong. */
  it('and the counts are read as cells', () => {
    expect(role).toMatch(/call read_table first/);
    expect(role).toMatch(/Use read if the answer is prose rather than a table/);
  });

  /* One unreachable tab must not cost the other five — the last walk lost half the audit that way. */
  it('a tab that will not open costs itself, not the audit', () => {
    expect(role).toMatch(/MOVE TO THE NEXT/);
    expect(role).toMatch(/Six tabs read is a whole audit/);
  });

  /* The language hints stay as a fallback for READING values, not as a way to find the tabs. */
  it('and the language hints remain for reading, not for hunting', () => {
    expect(role).toMatch(/match by MEANING, never by the English word/);
  });
});

describe('seeing and stopping are not powers', () => {
  /*
   * MEASURED, NOT ASSUMED. Across 2,272 recorded runs, 418 asked for a tool their role does not
   * carry and 112 of those reported success regardless. The top of the list was diagnostics
   * (~120 refusals), current_url (~80) and finish (~100, fifty of them to a single role).
   *
   * A role denied `finish` cannot end its own work — it runs to the step limit every time, which is
   * then filed as having wandered off. A role denied `diagnostics` meets a blank page with no way to
   * learn that the request was refused. Neither restriction protects anything.
   */
  const namesFor = (role) => toolsFor(role, TOOLS).map((t) => (t.function || t).name);

  for (const role of ['research.web', 'reach.search', 'facebook.scout', 'useme.proposal']) {
    it(`${role} can always see where it is and stop`, () => {
      const n = namesFor(role);
      for (const must of ['look', 'read', 'note', 'finish', 'current_url', 'diagnostics', 'use_role']) {
        expect(n, `${role} is missing ${must}`).toContain(must);
      }
    });
  }

  it('A ROLE AUTHORED WITH A BARE LIST STILL GETS THE FLOOR', () => {
    /* The roles the master writes keep omitting the obvious, because nobody lists it. One of them
       was refused `finish` fifty times and could never stop on its own. */
    const HAND = { name: 'hand-written', site: '', tools: ['click'] };
    useExternal({ getRole: (k) => (k === 'hand-written' ? HAND : null), listRoles: () => [HAND] });
    try {
      const n = namesFor('hand-written');
      expect(n).toContain('click');
      expect(n).toContain('finish');
      expect(n).toContain('diagnostics');
    } finally { useExternal(null); }
  });

  it('does NOT quietly hand out the things a specialist is kept away from', () => {
    /* Acting where other people can see, and reading whole feeds, are genuine decisions. A floor
       that grew to include them would turn every specialist into a generalist. */
    const own = get('reach.search').tools || [];
    const n = namesFor('reach.search');
    for (const notFree of ['sweep', 'act']) {
      if (!own.includes(notFree)) expect(n, `${notFree} must not be free`).not.toContain(notFree);
    }
  });
});

describe('a role can always stop being itself', () => {
  /*
   * MEASURED TWICE ON LIVE RUNS. A download was refused to facebook.scout because the owner was in
   * the facebook profile; save_place was refused to google.research because the profile was google.
   * Neither job was possible from its first step, and in both the agent kept working without the
   * one tool it needed — sixty-two steps, in the second case, for nothing.
   *
   * A role that cannot leave itself leaves the wall exactly where it stood, so use_role belongs to
   * every role or it belongs to none. This is not a hole in the restrictions: what they are FOR is
   * the acts other people can see, and those stay approval-gated whatever role is worn.
   */
  const { TOOLS } = require('../src/agent.js');
  it('every role carries use_role, including the most restricted', () => {
    for (const r of list()) {
      const n = toolsFor(r.name, TOOLS).map((x) => (x.function || x).name);
      expect(n, `${r.name} cannot change role`).toContain('use_role');
    }
  });

  it('and general can reach what a specialist was refused', () => {
    /* The escape has to lead somewhere. If general lacked save_place too, switching would be a
       gesture. */
    const g = toolsFor('general', TOOLS).map((x) => (x.function || x).name);
    expect(g).toContain('save_place');
    expect(g).toContain('download_link');
  });
});