qiksy-mcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/server.mjs +623 -20
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "MCP bridge for the Qiksy browser extension — expose live QA findings, forms, network and session to any MCP-capable coding agent.",
5
5
  "type": "module",
6
6
  "bin": {
package/server.mjs CHANGED
@@ -30,7 +30,7 @@ import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
30
30
  import { basename, extname, dirname, isAbsolute, resolve, join } from 'node:path';
31
31
  import { homedir } from 'node:os';
32
32
  import { fileURLToPath } from 'node:url';
33
- import { LIBRARIES, datePlan, hintsFor, recipeFor, classifyNode, classifySnapshot, classifyBehaviour } from './widgets.mjs';
33
+ import { LIBRARIES, datePlan, hintsFor, recipeFor, classifyNode, classifySnapshot, classifyBehaviour, diffDigest } from './widgets.mjs';
34
34
 
35
35
  const log = (...a) => console.error('[qiksy-mcp]', ...a);
36
36
 
@@ -338,12 +338,18 @@ const postcard = ({ earned = false } = {}) => say('praise', { earned });
338
338
  const seen = { lastReplyAt: 0, target: '', targetHits: 0, misses: 0, drives: 0, inFlight: 0, pending: null };
339
339
  const DRIVING = new Set([
340
340
  'qa_click', 'qa_press', 'qa_type', 'qa_type_many', 'qa_fill_json', 'qa_upload',
341
- 'qa_pick_date', 'qa_pick_time', 'qa_pick_range', 'qa_pick_path', 'qa_pick_tags',
341
+ 'qa_pick_date', 'qa_pick_time', 'qa_pick_range', 'qa_pick_path', 'qa_pick_tags', 'qa_focus_walk',
342
342
  ]);
343
343
  const THINK_MS = 45_000;
344
344
 
345
+ /* qa_focus_walk fires ~80 internal qa_press calls through the same choke point —
346
+ * without this mute one walk inflates the drive counter enough to trip the
347
+ * "long haul, no recipe" quip by itself (review note, 31.07.2026). */
348
+ let voiceMuted = false;
349
+
345
350
  /** A tool has started talking to the extension: measure the silence that just ended. */
346
351
  function noticedCall(tool) {
352
+ if (voiceMuted) return;
347
353
  if (!DRIVING.has(tool)) return;
348
354
  /* Only the FIRST extension call of a tool says anything about thinking time. A composite verb
349
355
  makes five in a row, and the gaps between those are the page's, not the model's. */
@@ -1049,6 +1055,40 @@ function pickNode(nodes, want, used, { fieldsOnly = false } = {}) {
1049
1055
  return null;
1050
1056
  }
1051
1057
 
1058
+ /**
1059
+ * WHAT A CONTROL SHOWS — as opposed to what it is CALLED.
1060
+ *
1061
+ * The two are the same on most controls and opposite on the one kind this file kept getting wrong:
1062
+ * a listbox trigger with an `aria-label`. The React stand's downshift button is
1063
+ * `getToggleButtonProps({ 'aria-label': 'Signatory role' })`, so `accessibleName` returns "Signatory
1064
+ * role" for ever while the thing a human reads on it is "Director" — judged by its name, a perfect
1065
+ * fill reads as empty and so does a perfect repair (the same trap that reported three correct MUI
1066
+ * dates as failures, 2026-07-30). `qa_read` returns innerText, which is the rendered truth; the
1067
+ * state rides in front of it because a native control keeps its value there (`value="…"`) and has
1068
+ * no text at all.
1069
+ */
1070
+ async function shownText(tabId, node) {
1071
+ if (!node) return '';
1072
+ const state = String(node.state || '');
1073
+ /* A CONTROL WITH ITS LIST UP IS NOT READABLE, and this is the one place a guess is forbidden:
1074
+ several libraries render the options INSIDE the trigger's own element, so an open menu puts
1075
+ every option into innerText and "Director" is found in a control that holds nothing — a fill
1076
+ reporting success while nothing landed, which is the failure that has already cost this
1077
+ product two sessions. `expanded` is the page's own word for "the list is up", so the text is
1078
+ not consulted at all; the caller then treats it as not-landed, which is the safe direction. */
1079
+ if (/\bexpanded\b/.test(state)) return state;
1080
+ let txt = '';
1081
+ const byRef = await callExtension('qa_read', { tabId, ref: node.ref, maxChars: 90 }).catch(() => null);
1082
+ if (byRef && byRef.ok !== false) txt = String(byRef.text || '');
1083
+ /* A ref belongs to ONE snapshot and a React re-render detaches what it points at ("ref is
1084
+ stale"); the cssPath is already on the node and survives that. */
1085
+ if (!txt && node.selector) {
1086
+ const bySel = await callExtension('qa_read', { tabId, selector: node.selector, maxChars: 90 }).catch(() => null);
1087
+ if (bySel && bySel.ok !== false) txt = String(bySel.text || '');
1088
+ }
1089
+ return `${state} ${txt}`.trim();
1090
+ }
1091
+
1052
1092
  /**
1053
1093
  * Fill a form from a plain { "field label": "value" } object — the shape of the JSON behind the
1054
1094
  * panel's own `{ }` editor and its ⚡Fill button (`Dataset` in engine/dataset.ts).
@@ -1062,7 +1102,7 @@ function pickNode(nodes, want, used, { fieldsOnly = false } = {}) {
1062
1102
  * The unmatched keys come back WITH the page's actual field names. That is the difference between
1063
1103
  * an agent that fixes its own key on the next call and one that starts snapshotting to find out.
1064
1104
  */
1065
- async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1105
+ async function fillByNames(tabId, fields, { dryRun, within, verify, advance, next } = {}) {
1066
1106
  const snap = await snapshotUntrapped(tabId, Object.keys(fields || {})[0]);
1067
1107
  rememberSnapshot(tabId, snap);
1068
1108
  let nodes = Array.isArray(snap?.nodes) ? snap.nodes : [];
@@ -1225,7 +1265,7 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1225
1265
  // The page's own field names, so the next call can use the right key without a round trip.
1226
1266
  out.fieldsOnThePage = nodes.filter((n) => isFieldNode(n) && n.name).map((n) => n.name).slice(0, 40);
1227
1267
  }
1228
- if (dryRun) return { ...out, dryRun: true };
1268
+ if (dryRun) return { ...out, dryRun: true, ...(advance ? { wouldAdvance: advance } : {}) };
1229
1269
  if (!batch.length) return { ok: false, error: 'none of those keys matched a field on the page', ...out };
1230
1270
 
1231
1271
  const filled = await callExtension('qa_type_many', { tabId, fields: batch }, 60_000);
@@ -1284,21 +1324,100 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1284
1324
  * context does. The extension's fill engine drives it correctly in about a second, so that is the
1285
1325
  * repair. Re-attempting raw with a longer wait was the wrong reflex: nothing about it was slow. */
1286
1326
  const fastFailed = !hasIt && node && /raw/.test(String(r.via || '')) && (node.role === 'combobox' || node.role === 'searchbox');
1287
- if (fastFailed) wrong.push({ ...r, engine: true });
1327
+ /* A BUTTON-TRIGGERED LISTBOX — the select that costs four agent calls on every single run.
1328
+ *
1329
+ * downshift's useSelect, Headless UI's Listbox.Button, MUI's Select: a <button> with
1330
+ * aria-haspopup=listbox and its own ul/li menu. The engine drives it by clicking the option,
1331
+ * and on downshift that does NOTHING — a synthetic click carries no mousedown and,
1332
+ * decisively, does not FOCUS, so nothing commits and any key the agent sends next lands on
1333
+ * <body>. Live (2026-07-30) the fill answered ok:true, the step's own validator still said
1334
+ * "still needed: Signatory role", and the agent then spent a snapshot + two presses + a
1335
+ * re-check doing by hand what keyboardPick has done all along — reachable only from pickPath.
1336
+ *
1337
+ * THE GATE IS NARROW ON PURPOSE, because the repair that UNDID a fill (an Ant multi-select
1338
+ * re-clicked and toggled back off — see `rewritable` above) is the one mistake this ladder
1339
+ * may never repeat:
1340
+ * · role `button` AND tag not `input` — `input[type=file]` reads as role button and an
1341
+ * upload control is not a listbox;
1342
+ * · `expanded`/`collapsed` in the state, i.e. the element really carries aria-expanded:
1343
+ * that is the shape of a popup trigger and not the shape of Next, Save or a chip;
1344
+ * · it never took a raw write (a button cannot; if `via` says raw this is not our case);
1345
+ * · and the decisive one — the RENDERED TEXT does not contain the value. The name cannot
1346
+ * answer here (it is an aria-label), so one qa_read is paid for exactly these nodes and
1347
+ * for nothing else. Note what this branch does NOT do: it never re-writes a value, so a
1348
+ * control whose value is merely unreadable cannot be damaged by it — it can only be
1349
+ * re-picked, and only when it renders nothing of what was asked for. */
1350
+ /* JUDGED BY THE TAG, NOT BY THE ROLE — corrected against a live run (2026-07-30). The branch
1351
+ first shipped requiring role === 'button', and downshift's trigger does not have it: the
1352
+ snapshot reads `aria-haspopup="listbox"` and calls the node a COMBOBOX whose tag is button.
1353
+ So the repair fell through to the combobox branch above ("raw, waited for the options"),
1354
+ which is exactly the path that had already failed — the fill reported ok and the step's own
1355
+ validator still asked for the field. The tag is the honest signal: a <button> cannot take a
1356
+ written value at all, whatever role the page claims for it, so the keyboard is the only way
1357
+ in. `input` is excluded because input[type=file] reads as role button, and an upload
1358
+ control is not a listbox. */
1359
+ const listboxTrigger =
1360
+ !hasIt && node && node.tag === 'button' &&
1361
+ !/raw/.test(String(r.via || '')) && /\b(expanded|collapsed)\b/.test(shown);
1362
+ const showsWanted = listboxTrigger ? norm(await shownText(tabId, node)).includes(want) : false;
1363
+ /* AND IT IS TESTED FIRST. A button-tagged trigger also satisfies the combobox branch below,
1364
+ and whichever runs first wins — that ordering is what made the fix inert on the very
1365
+ control it was written for. */
1366
+ if (listboxTrigger) { if (!showsWanted) wrong.push({ ...r, keyboard: true }); }
1367
+ else if (fastFailed) wrong.push({ ...r, engine: true });
1288
1368
  else if (!hasIt && node && (node.role === 'combobox' || node.role === 'searchbox') && r.via) wrong.push({ ...r, slow: true });
1289
1369
  else if (!hasIt && !showsSomething && node && rewritable) wrong.push(r);
1290
1370
  else if (!hasIt && !showsSomething && node) unreadable.push(r.key);
1291
1371
  }
1292
1372
  const repaired = [];
1293
1373
  for (const r of wrong) {
1374
+ /* THE KEYBOARD REPAIR RESOLVES ITS OWN TARGET, so it has to run BEFORE the three lines below.
1375
+ *
1376
+ * `pickNode(… { fieldsOnly: true })` looks only at form controls and this repair's control is
1377
+ * a <button>: the ordinary ladder finds nothing, hits `continue`, and the field is listed as
1378
+ * disagreed with no repair attempted at all. keyboardPick takes a NAME and does its own
1379
+ * resolution through findClickable — which is exactly why pickPath can call it too. This is a
1380
+ * second door onto the same room, not a second implementation. */
1381
+ if (r.keyboard) {
1382
+ const kb = await keyboardPick(tabId, r.matched || r.key, r.value, { fromHighlight: true });
1383
+ /* It refuses with the menu still up (the `loose < 0` return presses no Escape), and an
1384
+ abandoned popup is the next widget's bug. On SUCCESS Enter has already closed it, and
1385
+ pressing Escape at a page with no popup up is how a wizard inside a modal gets closed
1386
+ underneath us — so the Escape is spent only where there is something to close. */
1387
+ if (!kb.ok) await clearPopups(tabId);
1388
+ else routeMemory.set(routeKey(tabId, r.matched || r.key), 'keyboard');
1389
+ repaired.push({
1390
+ field: r.key,
1391
+ ok: kb.ok === true,
1392
+ via: kb.ok
1393
+ ? 'ArrowDown ×N + Enter at the trigger — this listbox ignores option clicks'
1394
+ : kb.error || 'the keyboard route did not take either',
1395
+ ...(kb.reads ? { reads: kb.reads } : {}),
1396
+ ...(kb.offers ? { offers: kb.offers } : {}),
1397
+ byKeyboard: true,
1398
+ });
1399
+ await new Promise((res) => setTimeout(res, 140));
1400
+ continue;
1401
+ }
1294
1402
  const snapN = await callExtension('qa_snapshot', { tabId }, 30_000);
1295
1403
  rememberSnapshot(tabId, snapN);
1296
1404
  const hit = pickNode(Array.isArray(snapN?.nodes) ? snapN.nodes : [], { name: r.matched || r.key }, new Set(), { fieldsOnly: true });
1297
1405
  if (!hit) continue;
1406
+ /* REPAIR THE WAY THE VALUE WENT IN, NOT THE OTHER WAY. Watched live on the stand
1407
+ (2026-07-30): an Ant TimePicker took "14:30" perfectly through `raw`, the snapshot could
1408
+ not quote the value back (its panel had not committed to the tree yet), the check called
1409
+ that a miss — and the repair went through the ENGINE, whose date path writes an ISO
1410
+ string. The field ended up holding "2026-07-30". A verification that turns a correct
1411
+ value into a wrong one is the most expensive bug this tool can have, so a text field that
1412
+ was filled raw is re-filled raw, with the same string in the same format the field
1413
+ displays. The engine repair stays for the composites it was written for (r.engine). */
1414
+ const rawFilled = /raw/.test(String(r.via || '')) && ['textbox', 'searchbox', 'spinbutton'].includes(hit.node.role);
1298
1415
  const again = r.slow
1299
1416
  ? await callExtension('qa_type', { tabId, ref: hit.node.ref, value: r.value, raw: true, commit: 'Enter', settleMs: 800 }, 20_000)
1300
- : await callExtension('qa_type', { tabId, ref: hit.node.ref, value: r.value }, 20_000);
1301
- repaired.push({ field: r.key, ok: again?.ok !== false, via: r.engine ? 'the fill engine (this library commits its own way)' : r.slow ? 'raw, waited for the options' : 'rewritten', ...(again?.stuck !== undefined ? { stuck: again.stuck } : {}) });
1417
+ : rawFilled && !r.engine
1418
+ ? await callExtension('qa_type', { tabId, ref: hit.node.ref, value: r.value, raw: true, commit: 'Enter', blur: true, settleMs: 300 }, 20_000)
1419
+ : await callExtension('qa_type', { tabId, ref: hit.node.ref, value: r.value }, 20_000);
1420
+ repaired.push({ field: r.key, ok: again?.ok !== false, via: r.engine ? 'the fill engine (this library commits its own way)' : r.slow ? 'raw, waited for the options' : rawFilled ? 'written again, verbatim' : 'rewritten', ...(again?.stuck !== undefined ? { stuck: again.stuck } : {}) });
1302
1421
  await new Promise((res) => setTimeout(res, 140));
1303
1422
  }
1304
1423
  if (unreadable.length && !wrong.length) {
@@ -1314,10 +1433,22 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1314
1433
  that opens the picker and one that reports the form as broken. */
1315
1434
  const stillWrong = repaired.filter((r) => r.ok === false || r.stuck === false).map((r) => r.field);
1316
1435
  const dateish = wrong.filter((r) => /^(day|month|year|hours|minutes|seconds|meridiem)$/i.test(String(r.matched || r.key)));
1436
+ /* SAID ONCE, FOR THE NEXT CALL — not repeated per field. A trigger that only answers the
1437
+ keyboard answers it every time, so an agent about to drive this control itself should press
1438
+ at the trigger rather than click the option, and an agent about to trust a reply about it
1439
+ should know its accessible name will never show the value. Only the ones that actually
1440
+ WORKED are listed: a keyboard attempt that failed is already in `stillWrong`. */
1441
+ /* BY A FLAG, NOT BY A REGEX OVER PROSE. The older repair path also says "at the trigger" in
1442
+ its `via`, so matching the sentence would sometimes name a field repaired by the other
1443
+ route and attach the wrong explanation to it. The flag is set at exactly one place. */
1444
+ const keyboardOnly = repaired.filter((x) => x.ok && x.byKeyboard).map((x) => x.field);
1317
1445
  verified = {
1318
1446
  disagreed: wrong.map((r) => r.key),
1319
1447
  repaired,
1320
1448
  ...(stillWrong.length ? { stillWrong } : {}),
1449
+ ...(keyboardOnly.length
1450
+ ? { keyboardOnly: { fields: keyboardOnly, why: 'a synthetic click on their options carries no mousedown and no focus, so it commits nothing — they were set with ArrowDown ×N + Enter addressed at the TRIGGER (qa_press { ref }), and their accessible name is an aria-label that never shows the value: read them with qa_read, not from the snapshot name' } }
1451
+ : {}),
1321
1452
  ...(dateish.length
1322
1453
  ? { note: 'those are the SECTIONS of a date field (MUI X and friends): the component rewrites them from its own state, so a written value never sticks. Open its calendar instead — qa_click the "Choose <field>" button, switch to the year view, pick the year, step the month, click the day.' }
1323
1454
  : {}),
@@ -1325,7 +1456,268 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1325
1456
  }
1326
1457
  }
1327
1458
 
1328
- return { ok: (filled?.filled || 0) > 0, ...out, filled: filled?.filled, total: filled?.total, results: filled?.results, ...(verified ? { verified } : {}) };
1459
+ /* ONE CALL PER STEP — the fill AND the Next, with the gate between them.
1460
+ *
1461
+ * Three independent reviews of today's 40-call run on demo/react landed on the same lever: a
1462
+ * wizard step costs five calls and four of them are bookkeeping — a snapshot to see the step, the
1463
+ * fill, a click on Next, a snapshot to see what the click did. This call already holds everything
1464
+ * those four need, so it presses Next itself and hands the NEXT step's field names back.
1465
+ *
1466
+ * THE GATE IS THE DESIGN, NOT THE CLICK. Advancing past a step that did not take its data is worse
1467
+ * than an extra call: the wizard keeps the half it got, the agent believes the step is behind it,
1468
+ * and the truth surfaces four steps later as a Submit that will not enable. So the control is
1469
+ * refused whenever this fill was anything but clean, and each refusal names its reason.
1470
+ *
1471
+ * `verified.unreadable` is deliberately NOT one of those reasons. A chips field never quotes its
1472
+ * value back to the tree, and reading "cannot be read" as "did not land" is the same mistake that
1473
+ * once made the repair toggle a multi-select back OFF — see the ladder above. */
1474
+ let advancing = null;
1475
+ if (advance) {
1476
+ const failed = (filled?.results || []).filter((r) => r && r.ok === false).length;
1477
+ const blocked = [];
1478
+ if (!((filled?.filled || 0) > 0)) blocked.push('the batch filled nothing');
1479
+ if (failed) blocked.push(`${failed} of the ${batch.length} entries came back ok:false`);
1480
+ if (verified?.stillWrong?.length) blocked.push(`still wrong after the repair: ${verified.stillWrong.join(', ')}`);
1481
+ if (unmatched.length) blocked.push(`these keys matched no control, so their values never went in: ${unmatched.join(', ')}`);
1482
+ if (blocked.length) {
1483
+ advancing = { advanced: { control: advance, clicked: false, ok: false, why: 'not pressed — this fill is not clean', blocked } };
1484
+ } else {
1485
+ /* A FAILING ADVANCE MUST NOT DESTROY THE FILL'S OWN REPORT. `callExtension` REJECTS on a
1486
+ transport error or a timeout, and an exception thrown here would be caught by the tool
1487
+ handler and returned as an error — throwing away the news that the fields went in. */
1488
+ advancing = await advanceAfterFill(tabId, advance, {
1489
+ // The PAGE's names for what was just written, not the dataset's keys: this is what the
1490
+ // after-snapshot is compared against to tell a real step swap from a Next that did nothing.
1491
+ filledNames: resolved.filter((r) => r.value !== 'click').map((r) => r.matched || r.key),
1492
+ wantNext: next !== false,
1493
+ }).catch((e) => ({
1494
+ advanced: { control: advance, clicked: false, ok: false, why: `the advance could not be driven: ${String(e?.message || e)}` },
1495
+ }));
1496
+ }
1497
+ }
1498
+
1499
+ return { ok: (filled?.filled || 0) > 0, ...out, filled: filled?.filled, total: filled?.total, results: filled?.results, ...(verified ? { verified } : {}), ...(advancing || {}) };
1500
+ }
1501
+
1502
+ /** Required controls that show no value — minus the ones this call just filled.
1503
+ *
1504
+ * The subtraction is the whole point. A control whose value the tree cannot quote (Ant's chips, a
1505
+ * portalled trigger) reads as empty here, and naming a field we have just written as "still needed"
1506
+ * is an invitation to write it again — which on a multi-select TOGGLES IT OFF. That bug is
1507
+ * documented in the verify ladder above and must not come back through a hint. */
1508
+ function stillNeededOn(nodes, filledNames) {
1509
+ const mine = new Set((filledNames || []).map(norm).filter(Boolean));
1510
+ return nodes
1511
+ .filter((n) => isFieldNode(n) && n.name && !mine.has(norm(n.name)))
1512
+ .filter((n) => /required/.test(String(n.state || '')) && !/value=|checked|selected/.test(String(n.state || '')))
1513
+ .map((n) => n.name)
1514
+ .slice(0, 12);
1515
+ }
1516
+
1517
+ /** Names that mean "this control goes forward" — so the reply can say what to pass as `advance` next. */
1518
+ const FORWARD_NAME = /^(next|continue|proceed|forward|submit|finish|complete|done|save|apply|create|confirm|sign)\b/i;
1519
+
1520
+ /**
1521
+ * THE STEP YOU ARE NOW ON, in the only currency that survives a re-render: names.
1522
+ *
1523
+ * Deliberately not refs and not a tree. The agent's next call is another qa_fill_json keyed by
1524
+ * label, so a list of labels is exactly what it needs and the cheapest thing to carry — and a ref
1525
+ * would be void by then anyway, because that call takes its own snapshot and renumbers everything.
1526
+ */
1527
+ function describeStep(nodes, url) {
1528
+ /* A TRIGGER IS A FIELD even when the snapshot calls it a button. Headless UI's Listbox and
1529
+ downshift's useSelect render `<button aria-haspopup="listbox">`, which fillByNames finds with
1530
+ its second, filter-less look — so leaving it out of the list here would under-report the step
1531
+ it is about to fill. The snapshot does not carry aria-haspopup, but it does carry the expanded
1532
+ state, and a button that can expand is a control with a value, not a navigation button. */
1533
+ const looksField = (n) => isFieldNode(n) || ((n.role === 'button' || n.role === 'link') && /expanded|collapsed/.test(String(n.state || '')));
1534
+ const headings = nodes.filter((n) => n.role === 'heading' && n.name);
1535
+ // h2/h3 first: the h1 is the page, the h2 is the step (demo/react renders `<h2>{step} · …</h2>`).
1536
+ const heading = headings.find((n) => /^h[23]$/.test(n.tag)) || headings[0];
1537
+ const fields = [];
1538
+ for (const n of nodes) {
1539
+ if (fields.length >= 40) break;
1540
+ if (!looksField(n) || !n.name) continue;
1541
+ // The star is cosmetic — norm() strips a trailing one, so the name still works as a key verbatim.
1542
+ fields.push(/required/.test(String(n.state || '')) ? `${n.name} *` : n.name);
1543
+ }
1544
+ const buttons = nodes.filter((n) => (n.role === 'button' || n.role === 'link') && n.name);
1545
+ const forward = [...new Set(buttons.filter((n) => FORWARD_NAME.test(n.name.trim())).map((n) => n.name))].slice(0, 6);
1546
+ return {
1547
+ url,
1548
+ ...(heading ? { title: heading.name } : {}),
1549
+ fields,
1550
+ ...(forward.length ? { advanceLooksLike: forward } : {}),
1551
+ note: 'the controls of the step you are on NOW, read from the snapshot taken after the click — you do not need a qa_snapshot of your own. A trailing * means the control reports itself required and is ignored when matching; two controls with the same label are addressed as "Label" and "Label#2".',
1552
+ };
1553
+ }
1554
+
1555
+ /**
1556
+ * PRESS THE ADVANCE CONTROL — and be honest about whether the page actually moved.
1557
+ *
1558
+ * Lifted from applyRecipeStep's advance block, which has been right about the hard part since the
1559
+ * first live wizard: THE CONTROL IS RE-RESOLVED, NEVER REUSED. Its ref came from a snapshot taken
1560
+ * before the fill, filling re-renders, and a new snapshot RENUMBERS every ref — that is how a run
1561
+ * once pressed "Back" while the click reported success. A NAME survives both, so it is resolved
1562
+ * here, against a snapshot taken at this moment.
1563
+ *
1564
+ * Two rules this adds, both because the click's own `ok` is worth nothing for this question:
1565
+ *
1566
+ * 1. A DISABLED Next accepts the click and does nothing. The React stand spells it
1567
+ * `disabled={!!missing[step - 1].length}`, and the snapshot already carries the answer —
1568
+ * stateOf() emits `disabled` — so the state is READ before clicking instead of inferred after.
1569
+ * One 400ms retry first: a form that validates on a debounce is still disabled a moment after
1570
+ * the last write and then enables itself.
1571
+ * 2. "It advanced" is a property of the DIFF. A step swap replaces its fields wholesale, so both
1572
+ * sides are large — the same evidence the `navigated` behaviour keys on in widgets.mjs. Nothing
1573
+ * else produces it: a dropdown opening only appends, a validation message only appends. The
1574
+ * extension caps each side of the diff at 12 nodes, so the thresholds stay small on purpose.
1575
+ */
1576
+ async function advanceAfterFill(tabId, advanceName, { filledNames = [], wantNext = true } = {}) {
1577
+ const look = async () => {
1578
+ const s = await callExtension('qa_snapshot', { tabId }, 30_000);
1579
+ rememberSnapshot(tabId, s);
1580
+ const ns = Array.isArray(s?.nodes) ? s.nodes : [];
1581
+ const pool = ns.filter((n) => CLICKABLE_ROLES.has(n.role));
1582
+ /* EXACT FIRST, AND SEPARATELY FROM pickNode's SOFT TIERS. An open react-datepicker puts "Next
1583
+ Month" and "Next Year" in the tree, and a library with a focus trap sets aria-hidden on
1584
+ everything outside its popup — which takes the real Next OUT of the tree entirely. pickNode
1585
+ would then prefix-match "Next" onto "Next Year" and steer the calendar instead of the wizard. */
1586
+ const exact = pool.filter((n) => norm(n.name) === norm(advanceName));
1587
+ return { url: s?.url || '', nodes: ns, hit: exact.length ? { node: exact[0] } : null };
1588
+ };
1589
+ let seen = await look();
1590
+ let loose = false;
1591
+ if (!seen.hit) {
1592
+ /* ESCAPE ONLY WHEN A POPUP IS ACTUALLY OPEN, and this condition is the difference between a
1593
+ recovery and a disaster. A leftover list from the last field aria-hides the rest of the page
1594
+ (Radix, Headless UI), so the real Next is not in the tree at all and one Escape brings it
1595
+ back. But a wizard living in a DISMISSIBLE MODAL answers Escape by closing itself, and
1596
+ everything this call just wrote goes with it. An exact-match miss on its own means almost
1597
+ nothing — "Next step" is not "Next" — so the untrap is spent only when the tree shows an open
1598
+ popup, which a trapped page always does: the popup is the one subtree NOT aria-hidden. */
1599
+ const popupOpen = seen.nodes.some((n) => n.role === 'option' || n.role === 'treeitem' || /expanded/.test(String(n.state || '')));
1600
+ if (popupOpen) {
1601
+ await clearPopups(tabId);
1602
+ seen = await look();
1603
+ }
1604
+ if (!seen.hit) {
1605
+ const pool = seen.nodes.filter((n) => CLICKABLE_ROLES.has(n.role));
1606
+ const soft = pickNode(pool.length ? pool : seen.nodes, { name: advanceName }, new Set());
1607
+ if (soft) {
1608
+ seen = { ...seen, hit: soft };
1609
+ loose = true;
1610
+ }
1611
+ }
1612
+ }
1613
+ if (!seen.hit) {
1614
+ return {
1615
+ advanced: {
1616
+ control: advanceName,
1617
+ clicked: false,
1618
+ ok: false,
1619
+ why: `nothing clickable is called "${advanceName}"`,
1620
+ clickableOnThePage: seen.nodes.filter((n) => CLICKABLE_ROLES.has(n.role) && n.name).map((n) => n.name).slice(0, 20),
1621
+ },
1622
+ };
1623
+ }
1624
+ if (/disabled/.test(String(seen.hit.node.state || ''))) {
1625
+ await new Promise((r) => setTimeout(r, 400));
1626
+ const again = await look();
1627
+ if (again.hit) seen = again;
1628
+ }
1629
+ if (/disabled/.test(String(seen.hit.node.state || ''))) {
1630
+ const need = stillNeededOn(seen.nodes, filledNames);
1631
+ return {
1632
+ advanced: {
1633
+ control: seen.hit.node.name || advanceName,
1634
+ clicked: false,
1635
+ ok: false,
1636
+ why: 'the control is DISABLED — nothing was pressed',
1637
+ note: 'the step has not validated, so pressing it would have reported success and done nothing. Fill what is still missing and call again with the same `advance`.',
1638
+ ...(need.length ? { stillNeeded: need } : {}),
1639
+ },
1640
+ };
1641
+ }
1642
+
1643
+ // Resolved while the memory still holds the PRE-click page, so the journal records the button by
1644
+ // the name a human reads. stepsFromJournal treats a click as the thing that CLOSES a step.
1645
+ const item = targetOf(tabId, seen.hit.node.ref, seen.hit.node.selector);
1646
+ const urlBefore = seen.url;
1647
+ const clicked = await callExtension('qa_click', { tabId, ref: seen.hit.node.ref }, 20_000);
1648
+ if (clicked?.ok === false) {
1649
+ return { advanced: { control: item.name || advanceName, clicked: false, ok: false, why: `the extension refused the click: ${clicked.error || 'no reason given'}` } };
1650
+ }
1651
+ // A wizard mounts the next step's fields once the click settles, and the snapshot below is what
1652
+ // the agent will be told about — so give them the beat applyRecipeStep gives them.
1653
+ await new Promise((r) => setTimeout(r, 450));
1654
+
1655
+ const d = diffDigest(clicked?.changed ?? {});
1656
+ const moved = d.appeared.length >= 1 && d.disappeared.length >= 1;
1657
+ const swapped = d.appeared.length >= 3 && d.disappeared.length >= 3;
1658
+ const s = await callExtension('qa_snapshot', { tabId }, 30_000);
1659
+ const nodesAfter = Array.isArray(s?.nodes) ? s.nodes : [];
1660
+ const urlAfter = s?.url || urlBefore;
1661
+ const routed = !!(urlBefore && urlAfter && urlBefore !== urlAfter);
1662
+ /* THE CORROBORATION THE DIFF CANNOT GIVE: are the fields we just filled still here? A step that
1663
+ swapped took its own controls with it, which covers the small step whose swap does not reach the
1664
+ thresholds above. It is matched against EVERY named node, not just the field-shaped ones, so a
1665
+ trigger that is a button does not read as gone. And it only counts alongside a diff that moved:
1666
+ a select renames itself when it takes a value — during the FILL, not during this click — so on
1667
+ its own it would call a page that never budged a step swap. */
1668
+ const namesAfter = new Set(nodesAfter.filter((n) => n.name).map((n) => norm(n.name)).filter(Boolean));
1669
+ /* ONLY NAMES THAT WERE STILL ON THE PAGE A MOMENT AGO can count as "gone". A trigger whose
1670
+ accessible name IS its value (MUI's role=combobox div, a chips field) renames itself when it
1671
+ takes the value — during the FILL, not during this click — so its old name is already missing
1672
+ before the button is pressed. Counted naively that makes a page which never budged look like a
1673
+ step swap: one relabelled button ("Next" → "Saving…") satisfies `moved`, and fields that went
1674
+ nowhere satisfy `fieldsGone`. Measured against the PRE-click tree, the whole class is gone. */
1675
+ const namesBefore = new Set(seen.nodes.filter((n) => n.name).map((n) => norm(n.name)).filter(Boolean));
1676
+ const wanted = filledNames.map(norm).filter((n) => n && namesBefore.has(n));
1677
+ const gone = wanted.filter((n) => !namesAfter.has(n));
1678
+ const fieldsGone = namesAfter.size > 0 && wanted.length > 0 && gone.length >= Math.ceil(wanted.length * 0.6);
1679
+ const ok = routed || swapped || (moved && fieldsGone);
1680
+
1681
+ const why = [
1682
+ d.appeared.length || d.disappeared.length ? `${d.appeared.length} appeared, ${d.disappeared.length} disappeared` : 'the page did not move',
1683
+ routed ? `the url changed to ${urlAfter}` : '',
1684
+ wanted.length ? `${gone.length} of the ${wanted.length} fields just filled are no longer on the page` : '',
1685
+ ]
1686
+ .filter(Boolean)
1687
+ .join(' · ');
1688
+
1689
+ /* JOURNALLED ONLY IF IT MOVED. stepsFromJournal reads a click as the END of a step and stores it
1690
+ as that step's `advance`; a click that changed nothing ended nothing, and recording it would cut
1691
+ one step of a saved recipe in two. The entry carries the post-click url, which is harmless: a
1692
+ step takes its url from its first FILL, and a click's url is only ever used for the host filter. */
1693
+ /* JOURNALLED BEFORE THE MEMORY MOVES ON. `note` stamps the entry with the url held in snapMemory,
1694
+ and a recipe step is filtered by host — so remembering the after-page first would file a
1695
+ cross-origin advance under the NEW host and drop it from the saved recipe. Only if it moved:
1696
+ stepsFromJournal reads a click as the END of a step, and a click that changed nothing ended
1697
+ nothing — recording it would cut one recipe step in two. */
1698
+ if (ok) note('click', tabId, [item]);
1699
+ rememberSnapshot(tabId, s);
1700
+
1701
+ if (!ok) {
1702
+ const need = stillNeededOn(nodesAfter, filledNames);
1703
+ return {
1704
+ advanced: {
1705
+ control: item.name || advanceName,
1706
+ clicked: true,
1707
+ ok: false,
1708
+ why,
1709
+ note: moved
1710
+ ? 'something changed, but not in the shape of a step swap — you may still be on the same step. Look before you fill anything else.'
1711
+ : 'the control took the click and the page did not move, so you are still on this step: a Next that is not enabled does exactly this. Do not treat the step as done.',
1712
+ ...(loose ? { matchedLoosely: true } : {}),
1713
+ ...(need.length ? { stillNeeded: need } : {}),
1714
+ },
1715
+ };
1716
+ }
1717
+ return {
1718
+ advanced: { control: item.name || advanceName, clicked: true, ok: true, why, ...(loose ? { matchedLoosely: true } : {}) },
1719
+ ...(wantNext ? { next: describeStep(nodesAfter, urlAfter) } : {}),
1720
+ };
1329
1721
  }
1330
1722
 
1331
1723
  /* ── COMPOSITE WIDGETS, DRIVEN FROM HERE ─────────────────────────────────────────
@@ -2093,7 +2485,7 @@ async function pickMonth(tabId, name, month, year) {
2093
2485
  * addressed AT the trigger (`ref`), because keys sent to the focused element land on `<body>` when
2094
2486
  * nothing has focus. Verified: ArrowDown ×2 → Enter → "Director".
2095
2487
  */
2096
- async function keyboardPick(tabId, triggerName, optionText) {
2488
+ async function keyboardPick(tabId, triggerName, optionText, { fromHighlight = false } = {}) {
2097
2489
  const trail = [];
2098
2490
  const triggerRef = async () => (await findClickable(tabId, triggerName, null)).hit?.node?.ref;
2099
2491
  await callExtension('qa_press', { tabId, key: 'Escape' }, 15_000).catch(() => {});
@@ -2110,17 +2502,44 @@ async function keyboardPick(tabId, triggerName, optionText) {
2110
2502
  const idx = options.findIndex((n) => norm(n.name) === norm(optionText));
2111
2503
  const loose = idx < 0 ? options.findIndex((n) => norm(n.name).includes(norm(optionText))) : idx;
2112
2504
  if (loose < 0) return { ok: false, error: `"${optionText}" is not among the ${options.length} options this listbox offers`, offers: options.map((n) => n.name).slice(0, 12), trail };
2113
- trail.push({ optionIndex: loose + 1, of: options.length });
2505
+
2506
+ /* COUNT FROM WHERE THE HIGHLIGHT ACTUALLY IS — but only when the caller says the control may
2507
+ already hold a value. `loose + 1` presses assume the list opens with nothing highlighted: true
2508
+ of an EMPTY control, false of one that already has a selection, because every one of these
2509
+ libraries opens with the current value highlighted. A repair on a field holding "Owner" and
2510
+ asked for "Director" would then arrow straight past it and commit "Manager" — a WRONG value,
2511
+ put there by a repair, which is the worst thing this file can do. The library's own marker is
2512
+ in the snapshot we have just taken (aria-selected → `selected` in state), so the correction
2513
+ costs no extra call and the offset goes in the trail instead of being assumed silently.
2514
+ pickPath deliberately does NOT pass the flag: its trees reach here as a last resort and their
2515
+ `selected` can mean the previous pick rather than the keyboard's position — extension 1.3.0
2516
+ learned that one the hard way ("where the highlight starts is the library's business"). */
2517
+ const from = fromHighlight ? options.findIndex((n) => /\bselected\b/.test(String(n.state || ''))) : -1;
2518
+ const hops = loose - from;
2519
+ trail.push({ optionIndex: loose + 1, of: options.length, ...(from >= 0 ? { highlightedOnOpen: from + 1 } : {}) });
2114
2520
 
2115
2521
  ref = (await triggerRef()) || ref;
2116
- for (let i = 0; i <= loose; i++) {
2117
- await callExtension('qa_press', { tabId, key: 'ArrowDown', ref }, 15_000).catch(() => {});
2522
+ const arrow = hops >= 0 ? 'ArrowDown' : 'ArrowUp';
2523
+ for (let i = 0; i < Math.abs(hops); i++) {
2524
+ await callExtension('qa_press', { tabId, key: arrow, ref }, 15_000).catch(() => {});
2118
2525
  await new Promise((r) => setTimeout(r, 130));
2119
2526
  }
2120
2527
  await callExtension('qa_press', { tabId, key: 'Enter', ref }, 15_000).catch(() => {});
2121
2528
  await new Promise((r) => setTimeout(r, 380));
2122
2529
  const after = await findClickable(tabId, triggerName, null);
2123
- const reads = String(after.hit?.node?.name || '');
2530
+ /* JUDGE BY WHAT IT SHOWS, NOT ONLY BY WHAT IT IS CALLED — otherwise this verb reports failure on
2531
+ the exact control it was written for. downshift's trigger is
2532
+ getToggleButtonProps({ 'aria-label': 'Signatory role' }), and accessibleName (a11y.ts:119)
2533
+ takes aria-label first, so the name still reads "Signatory role" after a PERFECT pick and `ok`
2534
+ comes back false. The name is KEPT and the rendered text ADDED, never swapped: a cascader
2535
+ really does carry its value in its name. The change is monotone — ok can only go false→true —
2536
+ which is what makes it safe for pickPath's two existing callers; there the practical effect is
2537
+ that a keyboard route which already worked stops being reported as a failure and falling
2538
+ through to the click walk. `shownText` refuses to read an EXPANDED control, so an open menu's
2539
+ own option list can never be mistaken for the trigger's value. */
2540
+ const nameNow = String(after.hit?.node?.name || '');
2541
+ const rendered = after.hit?.node ? await shownText(tabId, after.hit.node) : '';
2542
+ const reads = `${nameNow} ${rendered}`.replace(/\s+/g, ' ').trim();
2124
2543
  const ok = norm(reads).includes(norm(optionText));
2125
2544
  return { ok, via: 'keyboard at the trigger', reads: reads.slice(0, 60), trail };
2126
2545
  }
@@ -3092,7 +3511,15 @@ EVERY control kind by name-and-value: native selects, checkboxes, radios, slider
3092
3511
  the ones that look like they need clicking — Radix/shadcn, MUI, Ant and react-select dropdowns,
3093
3512
  typeahead comboboxes, popup date and time pickers (it opens the popover, waits for the options,
3094
3513
  picks the match and checks the trigger changed). A qa_type_many batch where half the entries are
3095
- dropdowns and dates is the normal use, not an edge case. So a wizard step is about three calls:
3514
+ dropdowns and dates is the normal use, not an edge case. When you already know the values, a wizard
3515
+ step is ONE call:
3516
+ qa_fill_json { fields: { …the whole step… }, advance: "Next" }
3517
+ → fills, verifies, presses Next only if every entry landed, and rides the NEXT step's field
3518
+ names back in \`next.fields\` — so the step after this one needs no snapshot of yours either.
3519
+ \`advanced.ok\` is read off the click's DIFF, never off its ok: a Next that stays disabled
3520
+ until the step validates accepts a click and does nothing, and this says so instead of
3521
+ moving on. It is not pressed at all when an entry failed or a key matched nothing.
3522
+ Three calls when you do not know the field names yet, or must pick one control among duplicates:
3096
3523
  qa_snapshot { fields_only: true } → the step's controls, not the app
3097
3524
  qa_type_many { fields: [ …every field of the step… ] } → widgets and all, in order
3098
3525
  qa_click { name: "Next" } → its \`changed\` tells you it advanced
@@ -3297,6 +3724,32 @@ server.registerTool(
3297
3724
  },
3298
3725
  );
3299
3726
 
3727
+ server.registerTool(
3728
+ 'qa_axe',
3729
+ {
3730
+ title: 'Run the axe-core rule engine on the page',
3731
+ description:
3732
+ 'The industry-standard accessibility rule engine (axe-core — the one under Lighthouse), bundled ' +
3733
+ 'INSIDE the extension and run locally on the live DOM: nothing leaves the page, no remote code. ' +
3734
+ 'Hundreds of WCAG A/AA checks on top of qa_run_audit\'s own set, narrowed to WCAG-tagged rules so ' +
3735
+ 'every violation maps to a criterion. Returns violations with impact, WCAG tags, help URL and up ' +
3736
+ 'to 10 offending selectors each. Read-only. Needs an extension build that carries the engine — an ' +
3737
+ 'older build answers "unknown command: axe" (reload the extension after updating).',
3738
+ inputSchema: { tabId: tabIdArg },
3739
+ },
3740
+ async ({ tabId }, extra) => {
3741
+ const bar = loader(extra, 'running axe-core');
3742
+ try {
3743
+ // axe walks the full DOM — give it more room than the house 30s default.
3744
+ return asText(await callExtension('axe', { tabId }, 60_000));
3745
+ } catch (e) {
3746
+ return asError(e);
3747
+ } finally {
3748
+ bar.stop();
3749
+ }
3750
+ },
3751
+ );
3752
+
3300
3753
  server.registerTool(
3301
3754
  'qa_read',
3302
3755
  {
@@ -3730,7 +4183,7 @@ server.registerTool(
3730
4183
  {
3731
4184
  title: 'Click an element (Pro)',
3732
4185
  description:
3733
- 'Click an element on the page under test — by a qa_snapshot ref, by a CSS selector, or by the control\'s accessible `name` (the one to use right after a fill, since filling re-renders the step and staleness is what makes a ref refuse). Sends the full pointer sequence (pointerdown → mouseup → click), so component libraries that listen for pointerdown — Radix, shadcn, MUI — react to it like a real click. Scrolls the element into view first, and refuses a target that matches nothing or has zero size rather than silently doing nothing. Returns what the click CHANGED — what appeared, disappeared or updated once the page settled — so you do not need a follow-up qa_snapshot to find out whether it did anything. `changed: null` means the page did not move, which is itself the answer.' +
4186
+ 'Click an element on the page under test — by a qa_snapshot ref, by a CSS selector, or by the control\'s accessible `name` (the one to use right after a fill, since filling re-renders the step and staleness is what makes a ref refuse). Sends the full pointer sequence (pointerdown → mouseup → click), so component libraries that listen for pointerdown — Radix, shadcn, MUI — react to it like a real click. Scrolls the element into view first, and refuses a target that matches nothing or has zero size rather than silently doing nothing. Returns what the click CHANGED — what appeared, disappeared or updated once the page settled — so you do not need a follow-up qa_snapshot to find out whether it did anything. `changed: null` means the page did not move, which is itself the answer. On a recent extension build the reply also carries `announced` — what the page\'s live regions (aria-live / role=status / role=alert) actually spoke during the settle window, or null if they stayed silent: a toast that appeared in `changed` but not in `announced` is invisible to a screen reader (WCAG 4.1.3).' +
3734
4187
  DRIVE_NOTE,
3735
4188
  inputSchema: {
3736
4189
  tabId: tabIdArg,
@@ -3908,7 +4361,7 @@ server.registerTool(
3908
4361
  {
3909
4362
  title: 'Press a key (Pro)',
3910
4363
  description:
3911
- 'Dispatch a keyboard key (keydown/keypress/keyup) — Enter to submit, Escape to close, Tab to move focus. Without a selector it goes to whatever currently has focus, like a real keyboard. Like qa_click, it returns what changed once the page settled.' +
4364
+ 'Dispatch a keyboard key (keydown/keypress/keyup) — Enter to submit, Escape to close, Tab to move focus. Without a selector it goes to whatever currently has focus, like a real keyboard. Like qa_click, it returns what changed once the page settled. On a recent extension build the reply also carries `focus` — the element that ACTUALLY holds focus after the key (name, role, selector, box), which is how you follow a Tab route or catch focus landing somewhere unnamed — and `announced`, what the live regions spoke (null = they stayed silent).' +
3912
4365
  DRIVE_NOTE,
3913
4366
  inputSchema: {
3914
4367
  tabId: tabIdArg,
@@ -4037,7 +4490,8 @@ server.registerTool(
4037
4490
  'DATE FIELDS BUILT FROM SECTIONS (MUI X DateField/DatePicker: separate Day / Month / Year spans the component owns) cannot be written to at all — the answer will say so under `verified`, and the way in is the calendar: click the "Choose …" button, switch to the year view, pick the year, step the month, click the day. ' +
4038
4491
  'A value of "click" PRESSES the thing instead of filling it — for the parts of a step that take no value: a chip, a custom radio (div role=radio), a switch, a toggle button. { "Climate": "click", "Medium": "click" } goes in the same batch as the text fields. ' +
4039
4492
  'REPEATED LABELS: a form built from a repeated component gives several controls the identical label — address them as "Full name", "Full name#2", "Full name#3", which is the same #N notation the panel\'s own dataset uses, so a dataset copied out of the `{ }` editor works as written. ' +
4040
- 'dry_run:true resolves the keys and fills nothing — read-only, and the cheap way to check a dataset against a page.' +
4493
+ 'ONE CALL PER WIZARD STEP: pass `advance: "Next"` and this presses that control after the fill — but only when the fill was clean. It is NOT pressed if an entry came back ok:false, if a key matched nothing, or if a value is still wrong after the repair, and the reply says which of those it was. Whether the page really advanced is decided by the DIFF the click produced (a step swap makes many nodes appear AND disappear), never by the click answering ok: a Next that is disabled until the step validates accepts a click and does nothing — that case is caught before the click, from the control\'s own state, and comes back as `advanced.ok:false` with `stillNeeded`. When it did advance, `next` carries the FIELD NAMES of the step you are now on, so the following step costs no qa_snapshot of your own; pass next:false to leave them out. ' +
4494
+ 'dry_run:true resolves the keys and fills nothing — read-only, and the cheap way to check a dataset against a page (with `advance` set it reports `wouldAdvance` and presses nothing).' +
4041
4495
  DRIVE_NOTE,
4042
4496
  inputSchema: {
4043
4497
  tabId: tabIdArg,
@@ -4053,12 +4507,20 @@ server.registerTool(
4053
4507
  .boolean()
4054
4508
  .optional()
4055
4509
  .describe('Default true: after the batch, read the page back and re-write any field whose value did not actually land, one at a time and re-found by name. This is what makes MUI-style multi-section fields and masked inputs work; pass false to skip the check.'),
4510
+ advance: z
4511
+ .string()
4512
+ .optional()
4513
+ .describe('After the fill, click the control with THIS accessible name — "Next", "Continue", "Submit". Resolved by name against a snapshot taken at that moment (a fill re-renders the step, which is what makes a ref taken earlier click the wrong button), skipped entirely when the control reports itself disabled, and refused when anything about the fill was not clean. The reply\'s `advanced.ok` tells you whether the page actually moved, judged by the click\'s diff.'),
4514
+ next: z
4515
+ .boolean()
4516
+ .optional()
4517
+ .describe('Default true when `advance` is set: ride the NEXT step\'s field names back in the same reply (`next.fields`, plus its title and what its forward control is called), so the step after this one costs you no qa_snapshot. Pass false when you do not need them.'),
4056
4518
  },
4057
4519
  },
4058
- async ({ tabId, fields, within, dry_run, verify }, extra) => {
4059
- const bar = loader(extra, dry_run ? 'matching the dataset to the page' : 'filling the form');
4520
+ async ({ tabId, fields, within, dry_run, verify, advance, next }, extra) => {
4521
+ const bar = loader(extra, dry_run ? 'matching the dataset to the page' : advance ? 'filling the form, then advancing' : 'filling the form');
4060
4522
  try {
4061
- return asText(await fillByNames(tabId, fields, { dryRun: dry_run, within, verify }));
4523
+ return asText(await fillByNames(tabId, fields, { dryRun: dry_run, within, verify, advance, next }));
4062
4524
  } catch (e) {
4063
4525
  return asError(e);
4064
4526
  } finally {
@@ -4224,6 +4686,147 @@ server.registerTool(
4224
4686
  },
4225
4687
  );
4226
4688
 
4689
+ server.registerTool(
4690
+ 'qa_focus_walk',
4691
+ {
4692
+ title: 'Walk the page the way a keyboard user does — Tab by Tab (Pro)',
4693
+ description:
4694
+ 'The keyboard route as ONE call: presses Tab repeatedly and records where focus actually lands, ' +
4695
+ 'then compares that trail against the interactive elements the accessibility tree claims. This is ' +
4696
+ 'the accessibility question a click can never answer — Qiksy clicks teleport to an element, a ' +
4697
+ 'keyboard user has to REACH it. Reports: the focus trail; interactive elements that never got ' +
4698
+ 'focus (the div-button a Tab user can never reach); a suspected focus TRAP when the same short ' +
4699
+ 'cycle of names repeats without the walk closing; and order-vs-geometry suspects — consecutive ' +
4700
+ 'stops where focus jumps far UP the page, the classic broken tab order. ' +
4701
+ 'Honest limits, stated because this tool feeds audits: matching is by accessible NAME (the press ' +
4702
+ 'reply carries no ref), so duplicate names can mask a missed element; focus VISIBILITY is not ' +
4703
+ 'judged here (that is qa_styles on a stop that interests you); and the walk changes the page\'s ' +
4704
+ 'focus for real, so run it before filling, not mid-form.' +
4705
+ DRIVE_NOTE,
4706
+ inputSchema: {
4707
+ tabId: tabIdArg,
4708
+ maxSteps: z.number().int().min(5).max(200).optional()
4709
+ .describe('Tab presses before giving up (default 80). A page that does not close its cycle within this is reported, not guessed at.'),
4710
+ },
4711
+ },
4712
+ async ({ tabId, maxSteps = 80 }, extra) => {
4713
+ const bar = loader(extra, 'walking the keyboard route');
4714
+ voiceMuted = true;
4715
+ try {
4716
+ /* The map first: what the tree CLAIMS is interactive, with geometry for the
4717
+ order check. A leftover popup would hide the page from the snapshot, so
4718
+ clear it the way every popup-opening verb here does. */
4719
+ await callExtension('qa_press', { tabId, key: 'Escape' }, 15_000).catch(() => {});
4720
+ const snap = await callExtension('qa_snapshot', { tabId, geometry: true }, 30_000);
4721
+ const nodes = Array.isArray(snap?.nodes) ? snap.nodes : [];
4722
+ const INTERACTIVE = new Set(['button', 'link', 'textbox', 'combobox', 'checkbox', 'radio', 'tab', 'menuitem', 'switch', 'slider', 'searchbox', 'listbox', 'spinbutton']);
4723
+ const interactive = nodes
4724
+ .filter((n) => INTERACTIVE.has(n.role) && (n.name || '').trim())
4725
+ .map((n) => ({ name: String(n.name).trim(), role: n.role, box: n.box || null }));
4726
+
4727
+ /* The walk. The press reply's `field` is the accessible name of whatever holds
4728
+ focus after the key — that is the trail. */
4729
+ const norm = (s) => String(s || '').trim().toLowerCase().replace(/[*:?]\s*$/, '');
4730
+ const trail = [];
4731
+ let trap = null;
4732
+ let loopClosed = false;
4733
+ let sawFocusField = false;
4734
+ let budgetHit = false;
4735
+ /* Wall-clock budget: each press pays actionDelta's settle window, and a page
4736
+ that never goes quiet pays the 2500ms hard cap per step — 80 steps would be
4737
+ ~200s pressed into a page nobody is listening to anymore (review B3). */
4738
+ const walkStart = Date.now();
4739
+ for (let i = 0; i < maxSteps; i++) {
4740
+ if (Date.now() - walkStart > 40_000) { budgetHit = true; break; }
4741
+ const r = await callExtension('qa_press', { tabId, key: 'Tab' }, 15_000);
4742
+ if (!r || r.ok === false) return asText({ ok: false, error: r?.error || 'Tab press failed', steps: i, trail: trail.slice(0, 100) });
4743
+ /* The 1.6.0 extension ALWAYS ships the `focus` key (null = nothing holds
4744
+ focus); its very absence in the reply is what distinguishes an old build
4745
+ from a page where focus never left body (review B2). The trusted-input
4746
+ path answers from the SW without a focus report — a different, honest
4747
+ error, because telling that user to update a current build is wrong. */
4748
+ if ('focus' in r) sawFocusField = true;
4749
+ else if (r.trusted) {
4750
+ return asText({
4751
+ ok: false,
4752
+ error: 'walk-unavailable-under-trusted-input',
4753
+ note: 'With Trusted input on, a bare key press answers from the background CDP path, which carries no focus report. Switch Trusted input off for the walk, or target presses at elements.',
4754
+ });
4755
+ }
4756
+ const at = String(('focus' in r ? r.focus?.name : r.field) ?? '').trim();
4757
+ trail.push(at);
4758
+ // full circle: back at the first stop after a real walk. Only NON-EMPTY
4759
+ // names close it — a run of unnamed stops matching an unnamed first stop
4760
+ // ended a login-form walk in five steps (review B1).
4761
+ if (i > 3 && at && trail[0] && norm(at) === norm(trail[0])) { loopClosed = true; break; }
4762
+ // trap: a short cycle (2–4 names) repeating twice in a row without progress
4763
+ for (let len = 2; len <= 4 && i + 1 >= len * 2; len++) {
4764
+ const a = trail.slice(-len).map(norm).join('|');
4765
+ const b = trail.slice(-len * 2, -len).map(norm).join('|');
4766
+ if (a === b && new Set(trail.slice(-len).map(norm)).size < len + 1) {
4767
+ trap = { cycle: [...new Set(trail.slice(-len))], after: i + 1 - len * 2 };
4768
+ break;
4769
+ }
4770
+ }
4771
+ if (trap) break;
4772
+ }
4773
+
4774
+ /* Post-analysis on the trail vs the map. Name-matched — the honest limit above. */
4775
+ const seen = new Set(trail.map(norm).filter(Boolean));
4776
+ const nameHit = (name) => {
4777
+ const n = norm(name);
4778
+ // press truncates long names — a trail entry that PREFIXES the map name counts
4779
+ return seen.has(n) || [...seen].some((s) => s.length >= 20 && n.startsWith(s));
4780
+ };
4781
+ const unreachable = interactive.filter((el) => !nameHit(el.name)).slice(0, 40)
4782
+ .map(({ name, role }) => ({ name: name.slice(0, 60), role }));
4783
+
4784
+ const boxOf = (name) => interactive.find((el) => norm(el.name) === norm(name) || norm(el.name).startsWith(norm(name)))?.box || null;
4785
+ const orderSuspects = [];
4786
+ for (let i = 1; i < trail.length && orderSuspects.length < 12; i++) {
4787
+ const a = boxOf(trail[i - 1]);
4788
+ const b = boxOf(trail[i]);
4789
+ if (a && b && b.y < a.y - 200 && b.x >= a.x - 40) {
4790
+ orderSuspects.push({ from: trail[i - 1].slice(0, 50), to: trail[i].slice(0, 50), why: `focus jumped ${Math.round(a.y - b.y)}px UP the page (2.4.3 candidate)` });
4791
+ }
4792
+ }
4793
+
4794
+ note('press', tabId, [{ name: 'focus walk', selector: '', value: `${trail.length} stops`, action: 'walk' }]);
4795
+ if (!sawFocusField) {
4796
+ return asText({
4797
+ ok: false,
4798
+ error: 'extension-too-old-for-walk',
4799
+ note:
4800
+ 'This Qiksy build does not report the focused element per keypress (`focus` in the press ' +
4801
+ 'reply) — the walk can only see container names, which makes trap detection and the ' +
4802
+ 'unreachable list unreliable. Update the Qiksy extension; the trail below is the honest ' +
4803
+ 'partial data, not a verdict.',
4804
+ steps: trail.length,
4805
+ trail: trail.slice(0, 100),
4806
+ });
4807
+ }
4808
+ return asText(withPostcard({
4809
+ ok: true,
4810
+ steps: trail.length,
4811
+ loopClosed,
4812
+ ...(budgetHit ? { note: 'stopped at the 40s walk budget — the page never settles between presses; the trail and the analysis cover the walked part only' } : {}),
4813
+ ...(trap ? { trap: { ...trap, note: 'the same short cycle of stops repeated — a focus trap candidate (2.1.2). Verify by hand before it enters a report.' } } : {}),
4814
+ interactiveInTree: interactive.length,
4815
+ reachedByName: interactive.length - unreachable.length,
4816
+ unreachable,
4817
+ orderSuspects,
4818
+ trail: trail.slice(0, 100),
4819
+ limits: 'name-matched (duplicate names can mask a miss); focus visibility not judged — qa_styles the stop you care about; the walk moves real focus, run it before filling',
4820
+ }, true));
4821
+ } catch (e) {
4822
+ return asError(e);
4823
+ } finally {
4824
+ voiceMuted = false;
4825
+ bar.stop();
4826
+ }
4827
+ },
4828
+ );
4829
+
4227
4830
  /* A widget whose SHAPE already settles it must not be clicked to find that out: probing costs a
4228
4831
  * call, and on some of these it costs more than a call (clicking into a MUI date section makes its
4229
4832
  * own picker button toggle the dialog shut — a regression I wrote myself). */