qiksy-mcp 1.4.0 → 1.5.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 +447 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qiksy-mcp",
3
- "version": "1.4.0",
3
+ "version": "1.5.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
 
@@ -1049,6 +1049,40 @@ function pickNode(nodes, want, used, { fieldsOnly = false } = {}) {
1049
1049
  return null;
1050
1050
  }
1051
1051
 
1052
+ /**
1053
+ * WHAT A CONTROL SHOWS — as opposed to what it is CALLED.
1054
+ *
1055
+ * The two are the same on most controls and opposite on the one kind this file kept getting wrong:
1056
+ * a listbox trigger with an `aria-label`. The React stand's downshift button is
1057
+ * `getToggleButtonProps({ 'aria-label': 'Signatory role' })`, so `accessibleName` returns "Signatory
1058
+ * role" for ever while the thing a human reads on it is "Director" — judged by its name, a perfect
1059
+ * fill reads as empty and so does a perfect repair (the same trap that reported three correct MUI
1060
+ * dates as failures, 2026-07-30). `qa_read` returns innerText, which is the rendered truth; the
1061
+ * state rides in front of it because a native control keeps its value there (`value="…"`) and has
1062
+ * no text at all.
1063
+ */
1064
+ async function shownText(tabId, node) {
1065
+ if (!node) return '';
1066
+ const state = String(node.state || '');
1067
+ /* A CONTROL WITH ITS LIST UP IS NOT READABLE, and this is the one place a guess is forbidden:
1068
+ several libraries render the options INSIDE the trigger's own element, so an open menu puts
1069
+ every option into innerText and "Director" is found in a control that holds nothing — a fill
1070
+ reporting success while nothing landed, which is the failure that has already cost this
1071
+ product two sessions. `expanded` is the page's own word for "the list is up", so the text is
1072
+ not consulted at all; the caller then treats it as not-landed, which is the safe direction. */
1073
+ if (/\bexpanded\b/.test(state)) return state;
1074
+ let txt = '';
1075
+ const byRef = await callExtension('qa_read', { tabId, ref: node.ref, maxChars: 90 }).catch(() => null);
1076
+ if (byRef && byRef.ok !== false) txt = String(byRef.text || '');
1077
+ /* A ref belongs to ONE snapshot and a React re-render detaches what it points at ("ref is
1078
+ stale"); the cssPath is already on the node and survives that. */
1079
+ if (!txt && node.selector) {
1080
+ const bySel = await callExtension('qa_read', { tabId, selector: node.selector, maxChars: 90 }).catch(() => null);
1081
+ if (bySel && bySel.ok !== false) txt = String(bySel.text || '');
1082
+ }
1083
+ return `${state} ${txt}`.trim();
1084
+ }
1085
+
1052
1086
  /**
1053
1087
  * Fill a form from a plain { "field label": "value" } object — the shape of the JSON behind the
1054
1088
  * panel's own `{ }` editor and its ⚡Fill button (`Dataset` in engine/dataset.ts).
@@ -1062,7 +1096,7 @@ function pickNode(nodes, want, used, { fieldsOnly = false } = {}) {
1062
1096
  * The unmatched keys come back WITH the page's actual field names. That is the difference between
1063
1097
  * an agent that fixes its own key on the next call and one that starts snapshotting to find out.
1064
1098
  */
1065
- async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1099
+ async function fillByNames(tabId, fields, { dryRun, within, verify, advance, next } = {}) {
1066
1100
  const snap = await snapshotUntrapped(tabId, Object.keys(fields || {})[0]);
1067
1101
  rememberSnapshot(tabId, snap);
1068
1102
  let nodes = Array.isArray(snap?.nodes) ? snap.nodes : [];
@@ -1225,7 +1259,7 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1225
1259
  // The page's own field names, so the next call can use the right key without a round trip.
1226
1260
  out.fieldsOnThePage = nodes.filter((n) => isFieldNode(n) && n.name).map((n) => n.name).slice(0, 40);
1227
1261
  }
1228
- if (dryRun) return { ...out, dryRun: true };
1262
+ if (dryRun) return { ...out, dryRun: true, ...(advance ? { wouldAdvance: advance } : {}) };
1229
1263
  if (!batch.length) return { ok: false, error: 'none of those keys matched a field on the page', ...out };
1230
1264
 
1231
1265
  const filled = await callExtension('qa_type_many', { tabId, fields: batch }, 60_000);
@@ -1284,21 +1318,100 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1284
1318
  * context does. The extension's fill engine drives it correctly in about a second, so that is the
1285
1319
  * repair. Re-attempting raw with a longer wait was the wrong reflex: nothing about it was slow. */
1286
1320
  const fastFailed = !hasIt && node && /raw/.test(String(r.via || '')) && (node.role === 'combobox' || node.role === 'searchbox');
1287
- if (fastFailed) wrong.push({ ...r, engine: true });
1321
+ /* A BUTTON-TRIGGERED LISTBOX — the select that costs four agent calls on every single run.
1322
+ *
1323
+ * downshift's useSelect, Headless UI's Listbox.Button, MUI's Select: a <button> with
1324
+ * aria-haspopup=listbox and its own ul/li menu. The engine drives it by clicking the option,
1325
+ * and on downshift that does NOTHING — a synthetic click carries no mousedown and,
1326
+ * decisively, does not FOCUS, so nothing commits and any key the agent sends next lands on
1327
+ * <body>. Live (2026-07-30) the fill answered ok:true, the step's own validator still said
1328
+ * "still needed: Signatory role", and the agent then spent a snapshot + two presses + a
1329
+ * re-check doing by hand what keyboardPick has done all along — reachable only from pickPath.
1330
+ *
1331
+ * THE GATE IS NARROW ON PURPOSE, because the repair that UNDID a fill (an Ant multi-select
1332
+ * re-clicked and toggled back off — see `rewritable` above) is the one mistake this ladder
1333
+ * may never repeat:
1334
+ * · role `button` AND tag not `input` — `input[type=file]` reads as role button and an
1335
+ * upload control is not a listbox;
1336
+ * · `expanded`/`collapsed` in the state, i.e. the element really carries aria-expanded:
1337
+ * that is the shape of a popup trigger and not the shape of Next, Save or a chip;
1338
+ * · it never took a raw write (a button cannot; if `via` says raw this is not our case);
1339
+ * · and the decisive one — the RENDERED TEXT does not contain the value. The name cannot
1340
+ * answer here (it is an aria-label), so one qa_read is paid for exactly these nodes and
1341
+ * for nothing else. Note what this branch does NOT do: it never re-writes a value, so a
1342
+ * control whose value is merely unreadable cannot be damaged by it — it can only be
1343
+ * re-picked, and only when it renders nothing of what was asked for. */
1344
+ /* JUDGED BY THE TAG, NOT BY THE ROLE — corrected against a live run (2026-07-30). The branch
1345
+ first shipped requiring role === 'button', and downshift's trigger does not have it: the
1346
+ snapshot reads `aria-haspopup="listbox"` and calls the node a COMBOBOX whose tag is button.
1347
+ So the repair fell through to the combobox branch above ("raw, waited for the options"),
1348
+ which is exactly the path that had already failed — the fill reported ok and the step's own
1349
+ validator still asked for the field. The tag is the honest signal: a <button> cannot take a
1350
+ written value at all, whatever role the page claims for it, so the keyboard is the only way
1351
+ in. `input` is excluded because input[type=file] reads as role button, and an upload
1352
+ control is not a listbox. */
1353
+ const listboxTrigger =
1354
+ !hasIt && node && node.tag === 'button' &&
1355
+ !/raw/.test(String(r.via || '')) && /\b(expanded|collapsed)\b/.test(shown);
1356
+ const showsWanted = listboxTrigger ? norm(await shownText(tabId, node)).includes(want) : false;
1357
+ /* AND IT IS TESTED FIRST. A button-tagged trigger also satisfies the combobox branch below,
1358
+ and whichever runs first wins — that ordering is what made the fix inert on the very
1359
+ control it was written for. */
1360
+ if (listboxTrigger) { if (!showsWanted) wrong.push({ ...r, keyboard: true }); }
1361
+ else if (fastFailed) wrong.push({ ...r, engine: true });
1288
1362
  else if (!hasIt && node && (node.role === 'combobox' || node.role === 'searchbox') && r.via) wrong.push({ ...r, slow: true });
1289
1363
  else if (!hasIt && !showsSomething && node && rewritable) wrong.push(r);
1290
1364
  else if (!hasIt && !showsSomething && node) unreadable.push(r.key);
1291
1365
  }
1292
1366
  const repaired = [];
1293
1367
  for (const r of wrong) {
1368
+ /* THE KEYBOARD REPAIR RESOLVES ITS OWN TARGET, so it has to run BEFORE the three lines below.
1369
+ *
1370
+ * `pickNode(… { fieldsOnly: true })` looks only at form controls and this repair's control is
1371
+ * a <button>: the ordinary ladder finds nothing, hits `continue`, and the field is listed as
1372
+ * disagreed with no repair attempted at all. keyboardPick takes a NAME and does its own
1373
+ * resolution through findClickable — which is exactly why pickPath can call it too. This is a
1374
+ * second door onto the same room, not a second implementation. */
1375
+ if (r.keyboard) {
1376
+ const kb = await keyboardPick(tabId, r.matched || r.key, r.value, { fromHighlight: true });
1377
+ /* It refuses with the menu still up (the `loose < 0` return presses no Escape), and an
1378
+ abandoned popup is the next widget's bug. On SUCCESS Enter has already closed it, and
1379
+ pressing Escape at a page with no popup up is how a wizard inside a modal gets closed
1380
+ underneath us — so the Escape is spent only where there is something to close. */
1381
+ if (!kb.ok) await clearPopups(tabId);
1382
+ else routeMemory.set(routeKey(tabId, r.matched || r.key), 'keyboard');
1383
+ repaired.push({
1384
+ field: r.key,
1385
+ ok: kb.ok === true,
1386
+ via: kb.ok
1387
+ ? 'ArrowDown ×N + Enter at the trigger — this listbox ignores option clicks'
1388
+ : kb.error || 'the keyboard route did not take either',
1389
+ ...(kb.reads ? { reads: kb.reads } : {}),
1390
+ ...(kb.offers ? { offers: kb.offers } : {}),
1391
+ byKeyboard: true,
1392
+ });
1393
+ await new Promise((res) => setTimeout(res, 140));
1394
+ continue;
1395
+ }
1294
1396
  const snapN = await callExtension('qa_snapshot', { tabId }, 30_000);
1295
1397
  rememberSnapshot(tabId, snapN);
1296
1398
  const hit = pickNode(Array.isArray(snapN?.nodes) ? snapN.nodes : [], { name: r.matched || r.key }, new Set(), { fieldsOnly: true });
1297
1399
  if (!hit) continue;
1400
+ /* REPAIR THE WAY THE VALUE WENT IN, NOT THE OTHER WAY. Watched live on the stand
1401
+ (2026-07-30): an Ant TimePicker took "14:30" perfectly through `raw`, the snapshot could
1402
+ not quote the value back (its panel had not committed to the tree yet), the check called
1403
+ that a miss — and the repair went through the ENGINE, whose date path writes an ISO
1404
+ string. The field ended up holding "2026-07-30". A verification that turns a correct
1405
+ value into a wrong one is the most expensive bug this tool can have, so a text field that
1406
+ was filled raw is re-filled raw, with the same string in the same format the field
1407
+ displays. The engine repair stays for the composites it was written for (r.engine). */
1408
+ const rawFilled = /raw/.test(String(r.via || '')) && ['textbox', 'searchbox', 'spinbutton'].includes(hit.node.role);
1298
1409
  const again = r.slow
1299
1410
  ? 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 } : {}) });
1411
+ : rawFilled && !r.engine
1412
+ ? await callExtension('qa_type', { tabId, ref: hit.node.ref, value: r.value, raw: true, commit: 'Enter', blur: true, settleMs: 300 }, 20_000)
1413
+ : await callExtension('qa_type', { tabId, ref: hit.node.ref, value: r.value }, 20_000);
1414
+ 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
1415
  await new Promise((res) => setTimeout(res, 140));
1303
1416
  }
1304
1417
  if (unreadable.length && !wrong.length) {
@@ -1314,10 +1427,22 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1314
1427
  that opens the picker and one that reports the form as broken. */
1315
1428
  const stillWrong = repaired.filter((r) => r.ok === false || r.stuck === false).map((r) => r.field);
1316
1429
  const dateish = wrong.filter((r) => /^(day|month|year|hours|minutes|seconds|meridiem)$/i.test(String(r.matched || r.key)));
1430
+ /* SAID ONCE, FOR THE NEXT CALL — not repeated per field. A trigger that only answers the
1431
+ keyboard answers it every time, so an agent about to drive this control itself should press
1432
+ at the trigger rather than click the option, and an agent about to trust a reply about it
1433
+ should know its accessible name will never show the value. Only the ones that actually
1434
+ WORKED are listed: a keyboard attempt that failed is already in `stillWrong`. */
1435
+ /* BY A FLAG, NOT BY A REGEX OVER PROSE. The older repair path also says "at the trigger" in
1436
+ its `via`, so matching the sentence would sometimes name a field repaired by the other
1437
+ route and attach the wrong explanation to it. The flag is set at exactly one place. */
1438
+ const keyboardOnly = repaired.filter((x) => x.ok && x.byKeyboard).map((x) => x.field);
1317
1439
  verified = {
1318
1440
  disagreed: wrong.map((r) => r.key),
1319
1441
  repaired,
1320
1442
  ...(stillWrong.length ? { stillWrong } : {}),
1443
+ ...(keyboardOnly.length
1444
+ ? { 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' } }
1445
+ : {}),
1321
1446
  ...(dateish.length
1322
1447
  ? { 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
1448
  : {}),
@@ -1325,7 +1450,268 @@ async function fillByNames(tabId, fields, { dryRun, within, verify } = {}) {
1325
1450
  }
1326
1451
  }
1327
1452
 
1328
- return { ok: (filled?.filled || 0) > 0, ...out, filled: filled?.filled, total: filled?.total, results: filled?.results, ...(verified ? { verified } : {}) };
1453
+ /* ONE CALL PER STEP — the fill AND the Next, with the gate between them.
1454
+ *
1455
+ * Three independent reviews of today's 40-call run on demo/react landed on the same lever: a
1456
+ * wizard step costs five calls and four of them are bookkeeping — a snapshot to see the step, the
1457
+ * fill, a click on Next, a snapshot to see what the click did. This call already holds everything
1458
+ * those four need, so it presses Next itself and hands the NEXT step's field names back.
1459
+ *
1460
+ * THE GATE IS THE DESIGN, NOT THE CLICK. Advancing past a step that did not take its data is worse
1461
+ * than an extra call: the wizard keeps the half it got, the agent believes the step is behind it,
1462
+ * and the truth surfaces four steps later as a Submit that will not enable. So the control is
1463
+ * refused whenever this fill was anything but clean, and each refusal names its reason.
1464
+ *
1465
+ * `verified.unreadable` is deliberately NOT one of those reasons. A chips field never quotes its
1466
+ * value back to the tree, and reading "cannot be read" as "did not land" is the same mistake that
1467
+ * once made the repair toggle a multi-select back OFF — see the ladder above. */
1468
+ let advancing = null;
1469
+ if (advance) {
1470
+ const failed = (filled?.results || []).filter((r) => r && r.ok === false).length;
1471
+ const blocked = [];
1472
+ if (!((filled?.filled || 0) > 0)) blocked.push('the batch filled nothing');
1473
+ if (failed) blocked.push(`${failed} of the ${batch.length} entries came back ok:false`);
1474
+ if (verified?.stillWrong?.length) blocked.push(`still wrong after the repair: ${verified.stillWrong.join(', ')}`);
1475
+ if (unmatched.length) blocked.push(`these keys matched no control, so their values never went in: ${unmatched.join(', ')}`);
1476
+ if (blocked.length) {
1477
+ advancing = { advanced: { control: advance, clicked: false, ok: false, why: 'not pressed — this fill is not clean', blocked } };
1478
+ } else {
1479
+ /* A FAILING ADVANCE MUST NOT DESTROY THE FILL'S OWN REPORT. `callExtension` REJECTS on a
1480
+ transport error or a timeout, and an exception thrown here would be caught by the tool
1481
+ handler and returned as an error — throwing away the news that the fields went in. */
1482
+ advancing = await advanceAfterFill(tabId, advance, {
1483
+ // The PAGE's names for what was just written, not the dataset's keys: this is what the
1484
+ // after-snapshot is compared against to tell a real step swap from a Next that did nothing.
1485
+ filledNames: resolved.filter((r) => r.value !== 'click').map((r) => r.matched || r.key),
1486
+ wantNext: next !== false,
1487
+ }).catch((e) => ({
1488
+ advanced: { control: advance, clicked: false, ok: false, why: `the advance could not be driven: ${String(e?.message || e)}` },
1489
+ }));
1490
+ }
1491
+ }
1492
+
1493
+ return { ok: (filled?.filled || 0) > 0, ...out, filled: filled?.filled, total: filled?.total, results: filled?.results, ...(verified ? { verified } : {}), ...(advancing || {}) };
1494
+ }
1495
+
1496
+ /** Required controls that show no value — minus the ones this call just filled.
1497
+ *
1498
+ * The subtraction is the whole point. A control whose value the tree cannot quote (Ant's chips, a
1499
+ * portalled trigger) reads as empty here, and naming a field we have just written as "still needed"
1500
+ * is an invitation to write it again — which on a multi-select TOGGLES IT OFF. That bug is
1501
+ * documented in the verify ladder above and must not come back through a hint. */
1502
+ function stillNeededOn(nodes, filledNames) {
1503
+ const mine = new Set((filledNames || []).map(norm).filter(Boolean));
1504
+ return nodes
1505
+ .filter((n) => isFieldNode(n) && n.name && !mine.has(norm(n.name)))
1506
+ .filter((n) => /required/.test(String(n.state || '')) && !/value=|checked|selected/.test(String(n.state || '')))
1507
+ .map((n) => n.name)
1508
+ .slice(0, 12);
1509
+ }
1510
+
1511
+ /** Names that mean "this control goes forward" — so the reply can say what to pass as `advance` next. */
1512
+ const FORWARD_NAME = /^(next|continue|proceed|forward|submit|finish|complete|done|save|apply|create|confirm|sign)\b/i;
1513
+
1514
+ /**
1515
+ * THE STEP YOU ARE NOW ON, in the only currency that survives a re-render: names.
1516
+ *
1517
+ * Deliberately not refs and not a tree. The agent's next call is another qa_fill_json keyed by
1518
+ * label, so a list of labels is exactly what it needs and the cheapest thing to carry — and a ref
1519
+ * would be void by then anyway, because that call takes its own snapshot and renumbers everything.
1520
+ */
1521
+ function describeStep(nodes, url) {
1522
+ /* A TRIGGER IS A FIELD even when the snapshot calls it a button. Headless UI's Listbox and
1523
+ downshift's useSelect render `<button aria-haspopup="listbox">`, which fillByNames finds with
1524
+ its second, filter-less look — so leaving it out of the list here would under-report the step
1525
+ it is about to fill. The snapshot does not carry aria-haspopup, but it does carry the expanded
1526
+ state, and a button that can expand is a control with a value, not a navigation button. */
1527
+ const looksField = (n) => isFieldNode(n) || ((n.role === 'button' || n.role === 'link') && /expanded|collapsed/.test(String(n.state || '')));
1528
+ const headings = nodes.filter((n) => n.role === 'heading' && n.name);
1529
+ // h2/h3 first: the h1 is the page, the h2 is the step (demo/react renders `<h2>{step} · …</h2>`).
1530
+ const heading = headings.find((n) => /^h[23]$/.test(n.tag)) || headings[0];
1531
+ const fields = [];
1532
+ for (const n of nodes) {
1533
+ if (fields.length >= 40) break;
1534
+ if (!looksField(n) || !n.name) continue;
1535
+ // The star is cosmetic — norm() strips a trailing one, so the name still works as a key verbatim.
1536
+ fields.push(/required/.test(String(n.state || '')) ? `${n.name} *` : n.name);
1537
+ }
1538
+ const buttons = nodes.filter((n) => (n.role === 'button' || n.role === 'link') && n.name);
1539
+ const forward = [...new Set(buttons.filter((n) => FORWARD_NAME.test(n.name.trim())).map((n) => n.name))].slice(0, 6);
1540
+ return {
1541
+ url,
1542
+ ...(heading ? { title: heading.name } : {}),
1543
+ fields,
1544
+ ...(forward.length ? { advanceLooksLike: forward } : {}),
1545
+ 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".',
1546
+ };
1547
+ }
1548
+
1549
+ /**
1550
+ * PRESS THE ADVANCE CONTROL — and be honest about whether the page actually moved.
1551
+ *
1552
+ * Lifted from applyRecipeStep's advance block, which has been right about the hard part since the
1553
+ * first live wizard: THE CONTROL IS RE-RESOLVED, NEVER REUSED. Its ref came from a snapshot taken
1554
+ * before the fill, filling re-renders, and a new snapshot RENUMBERS every ref — that is how a run
1555
+ * once pressed "Back" while the click reported success. A NAME survives both, so it is resolved
1556
+ * here, against a snapshot taken at this moment.
1557
+ *
1558
+ * Two rules this adds, both because the click's own `ok` is worth nothing for this question:
1559
+ *
1560
+ * 1. A DISABLED Next accepts the click and does nothing. The React stand spells it
1561
+ * `disabled={!!missing[step - 1].length}`, and the snapshot already carries the answer —
1562
+ * stateOf() emits `disabled` — so the state is READ before clicking instead of inferred after.
1563
+ * One 400ms retry first: a form that validates on a debounce is still disabled a moment after
1564
+ * the last write and then enables itself.
1565
+ * 2. "It advanced" is a property of the DIFF. A step swap replaces its fields wholesale, so both
1566
+ * sides are large — the same evidence the `navigated` behaviour keys on in widgets.mjs. Nothing
1567
+ * else produces it: a dropdown opening only appends, a validation message only appends. The
1568
+ * extension caps each side of the diff at 12 nodes, so the thresholds stay small on purpose.
1569
+ */
1570
+ async function advanceAfterFill(tabId, advanceName, { filledNames = [], wantNext = true } = {}) {
1571
+ const look = async () => {
1572
+ const s = await callExtension('qa_snapshot', { tabId }, 30_000);
1573
+ rememberSnapshot(tabId, s);
1574
+ const ns = Array.isArray(s?.nodes) ? s.nodes : [];
1575
+ const pool = ns.filter((n) => CLICKABLE_ROLES.has(n.role));
1576
+ /* EXACT FIRST, AND SEPARATELY FROM pickNode's SOFT TIERS. An open react-datepicker puts "Next
1577
+ Month" and "Next Year" in the tree, and a library with a focus trap sets aria-hidden on
1578
+ everything outside its popup — which takes the real Next OUT of the tree entirely. pickNode
1579
+ would then prefix-match "Next" onto "Next Year" and steer the calendar instead of the wizard. */
1580
+ const exact = pool.filter((n) => norm(n.name) === norm(advanceName));
1581
+ return { url: s?.url || '', nodes: ns, hit: exact.length ? { node: exact[0] } : null };
1582
+ };
1583
+ let seen = await look();
1584
+ let loose = false;
1585
+ if (!seen.hit) {
1586
+ /* ESCAPE ONLY WHEN A POPUP IS ACTUALLY OPEN, and this condition is the difference between a
1587
+ recovery and a disaster. A leftover list from the last field aria-hides the rest of the page
1588
+ (Radix, Headless UI), so the real Next is not in the tree at all and one Escape brings it
1589
+ back. But a wizard living in a DISMISSIBLE MODAL answers Escape by closing itself, and
1590
+ everything this call just wrote goes with it. An exact-match miss on its own means almost
1591
+ nothing — "Next step" is not "Next" — so the untrap is spent only when the tree shows an open
1592
+ popup, which a trapped page always does: the popup is the one subtree NOT aria-hidden. */
1593
+ const popupOpen = seen.nodes.some((n) => n.role === 'option' || n.role === 'treeitem' || /expanded/.test(String(n.state || '')));
1594
+ if (popupOpen) {
1595
+ await clearPopups(tabId);
1596
+ seen = await look();
1597
+ }
1598
+ if (!seen.hit) {
1599
+ const pool = seen.nodes.filter((n) => CLICKABLE_ROLES.has(n.role));
1600
+ const soft = pickNode(pool.length ? pool : seen.nodes, { name: advanceName }, new Set());
1601
+ if (soft) {
1602
+ seen = { ...seen, hit: soft };
1603
+ loose = true;
1604
+ }
1605
+ }
1606
+ }
1607
+ if (!seen.hit) {
1608
+ return {
1609
+ advanced: {
1610
+ control: advanceName,
1611
+ clicked: false,
1612
+ ok: false,
1613
+ why: `nothing clickable is called "${advanceName}"`,
1614
+ clickableOnThePage: seen.nodes.filter((n) => CLICKABLE_ROLES.has(n.role) && n.name).map((n) => n.name).slice(0, 20),
1615
+ },
1616
+ };
1617
+ }
1618
+ if (/disabled/.test(String(seen.hit.node.state || ''))) {
1619
+ await new Promise((r) => setTimeout(r, 400));
1620
+ const again = await look();
1621
+ if (again.hit) seen = again;
1622
+ }
1623
+ if (/disabled/.test(String(seen.hit.node.state || ''))) {
1624
+ const need = stillNeededOn(seen.nodes, filledNames);
1625
+ return {
1626
+ advanced: {
1627
+ control: seen.hit.node.name || advanceName,
1628
+ clicked: false,
1629
+ ok: false,
1630
+ why: 'the control is DISABLED — nothing was pressed',
1631
+ 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`.',
1632
+ ...(need.length ? { stillNeeded: need } : {}),
1633
+ },
1634
+ };
1635
+ }
1636
+
1637
+ // Resolved while the memory still holds the PRE-click page, so the journal records the button by
1638
+ // the name a human reads. stepsFromJournal treats a click as the thing that CLOSES a step.
1639
+ const item = targetOf(tabId, seen.hit.node.ref, seen.hit.node.selector);
1640
+ const urlBefore = seen.url;
1641
+ const clicked = await callExtension('qa_click', { tabId, ref: seen.hit.node.ref }, 20_000);
1642
+ if (clicked?.ok === false) {
1643
+ return { advanced: { control: item.name || advanceName, clicked: false, ok: false, why: `the extension refused the click: ${clicked.error || 'no reason given'}` } };
1644
+ }
1645
+ // A wizard mounts the next step's fields once the click settles, and the snapshot below is what
1646
+ // the agent will be told about — so give them the beat applyRecipeStep gives them.
1647
+ await new Promise((r) => setTimeout(r, 450));
1648
+
1649
+ const d = diffDigest(clicked?.changed ?? {});
1650
+ const moved = d.appeared.length >= 1 && d.disappeared.length >= 1;
1651
+ const swapped = d.appeared.length >= 3 && d.disappeared.length >= 3;
1652
+ const s = await callExtension('qa_snapshot', { tabId }, 30_000);
1653
+ const nodesAfter = Array.isArray(s?.nodes) ? s.nodes : [];
1654
+ const urlAfter = s?.url || urlBefore;
1655
+ const routed = !!(urlBefore && urlAfter && urlBefore !== urlAfter);
1656
+ /* THE CORROBORATION THE DIFF CANNOT GIVE: are the fields we just filled still here? A step that
1657
+ swapped took its own controls with it, which covers the small step whose swap does not reach the
1658
+ thresholds above. It is matched against EVERY named node, not just the field-shaped ones, so a
1659
+ trigger that is a button does not read as gone. And it only counts alongside a diff that moved:
1660
+ a select renames itself when it takes a value — during the FILL, not during this click — so on
1661
+ its own it would call a page that never budged a step swap. */
1662
+ const namesAfter = new Set(nodesAfter.filter((n) => n.name).map((n) => norm(n.name)).filter(Boolean));
1663
+ /* ONLY NAMES THAT WERE STILL ON THE PAGE A MOMENT AGO can count as "gone". A trigger whose
1664
+ accessible name IS its value (MUI's role=combobox div, a chips field) renames itself when it
1665
+ takes the value — during the FILL, not during this click — so its old name is already missing
1666
+ before the button is pressed. Counted naively that makes a page which never budged look like a
1667
+ step swap: one relabelled button ("Next" → "Saving…") satisfies `moved`, and fields that went
1668
+ nowhere satisfy `fieldsGone`. Measured against the PRE-click tree, the whole class is gone. */
1669
+ const namesBefore = new Set(seen.nodes.filter((n) => n.name).map((n) => norm(n.name)).filter(Boolean));
1670
+ const wanted = filledNames.map(norm).filter((n) => n && namesBefore.has(n));
1671
+ const gone = wanted.filter((n) => !namesAfter.has(n));
1672
+ const fieldsGone = namesAfter.size > 0 && wanted.length > 0 && gone.length >= Math.ceil(wanted.length * 0.6);
1673
+ const ok = routed || swapped || (moved && fieldsGone);
1674
+
1675
+ const why = [
1676
+ d.appeared.length || d.disappeared.length ? `${d.appeared.length} appeared, ${d.disappeared.length} disappeared` : 'the page did not move',
1677
+ routed ? `the url changed to ${urlAfter}` : '',
1678
+ wanted.length ? `${gone.length} of the ${wanted.length} fields just filled are no longer on the page` : '',
1679
+ ]
1680
+ .filter(Boolean)
1681
+ .join(' · ');
1682
+
1683
+ /* JOURNALLED ONLY IF IT MOVED. stepsFromJournal reads a click as the END of a step and stores it
1684
+ as that step's `advance`; a click that changed nothing ended nothing, and recording it would cut
1685
+ one step of a saved recipe in two. The entry carries the post-click url, which is harmless: a
1686
+ step takes its url from its first FILL, and a click's url is only ever used for the host filter. */
1687
+ /* JOURNALLED BEFORE THE MEMORY MOVES ON. `note` stamps the entry with the url held in snapMemory,
1688
+ and a recipe step is filtered by host — so remembering the after-page first would file a
1689
+ cross-origin advance under the NEW host and drop it from the saved recipe. Only if it moved:
1690
+ stepsFromJournal reads a click as the END of a step, and a click that changed nothing ended
1691
+ nothing — recording it would cut one recipe step in two. */
1692
+ if (ok) note('click', tabId, [item]);
1693
+ rememberSnapshot(tabId, s);
1694
+
1695
+ if (!ok) {
1696
+ const need = stillNeededOn(nodesAfter, filledNames);
1697
+ return {
1698
+ advanced: {
1699
+ control: item.name || advanceName,
1700
+ clicked: true,
1701
+ ok: false,
1702
+ why,
1703
+ note: moved
1704
+ ? '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.'
1705
+ : '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.',
1706
+ ...(loose ? { matchedLoosely: true } : {}),
1707
+ ...(need.length ? { stillNeeded: need } : {}),
1708
+ },
1709
+ };
1710
+ }
1711
+ return {
1712
+ advanced: { control: item.name || advanceName, clicked: true, ok: true, why, ...(loose ? { matchedLoosely: true } : {}) },
1713
+ ...(wantNext ? { next: describeStep(nodesAfter, urlAfter) } : {}),
1714
+ };
1329
1715
  }
1330
1716
 
1331
1717
  /* ── COMPOSITE WIDGETS, DRIVEN FROM HERE ─────────────────────────────────────────
@@ -2093,7 +2479,7 @@ async function pickMonth(tabId, name, month, year) {
2093
2479
  * addressed AT the trigger (`ref`), because keys sent to the focused element land on `<body>` when
2094
2480
  * nothing has focus. Verified: ArrowDown ×2 → Enter → "Director".
2095
2481
  */
2096
- async function keyboardPick(tabId, triggerName, optionText) {
2482
+ async function keyboardPick(tabId, triggerName, optionText, { fromHighlight = false } = {}) {
2097
2483
  const trail = [];
2098
2484
  const triggerRef = async () => (await findClickable(tabId, triggerName, null)).hit?.node?.ref;
2099
2485
  await callExtension('qa_press', { tabId, key: 'Escape' }, 15_000).catch(() => {});
@@ -2110,17 +2496,44 @@ async function keyboardPick(tabId, triggerName, optionText) {
2110
2496
  const idx = options.findIndex((n) => norm(n.name) === norm(optionText));
2111
2497
  const loose = idx < 0 ? options.findIndex((n) => norm(n.name).includes(norm(optionText))) : idx;
2112
2498
  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 });
2499
+
2500
+ /* COUNT FROM WHERE THE HIGHLIGHT ACTUALLY IS — but only when the caller says the control may
2501
+ already hold a value. `loose + 1` presses assume the list opens with nothing highlighted: true
2502
+ of an EMPTY control, false of one that already has a selection, because every one of these
2503
+ libraries opens with the current value highlighted. A repair on a field holding "Owner" and
2504
+ asked for "Director" would then arrow straight past it and commit "Manager" — a WRONG value,
2505
+ put there by a repair, which is the worst thing this file can do. The library's own marker is
2506
+ in the snapshot we have just taken (aria-selected → `selected` in state), so the correction
2507
+ costs no extra call and the offset goes in the trail instead of being assumed silently.
2508
+ pickPath deliberately does NOT pass the flag: its trees reach here as a last resort and their
2509
+ `selected` can mean the previous pick rather than the keyboard's position — extension 1.3.0
2510
+ learned that one the hard way ("where the highlight starts is the library's business"). */
2511
+ const from = fromHighlight ? options.findIndex((n) => /\bselected\b/.test(String(n.state || ''))) : -1;
2512
+ const hops = loose - from;
2513
+ trail.push({ optionIndex: loose + 1, of: options.length, ...(from >= 0 ? { highlightedOnOpen: from + 1 } : {}) });
2114
2514
 
2115
2515
  ref = (await triggerRef()) || ref;
2116
- for (let i = 0; i <= loose; i++) {
2117
- await callExtension('qa_press', { tabId, key: 'ArrowDown', ref }, 15_000).catch(() => {});
2516
+ const arrow = hops >= 0 ? 'ArrowDown' : 'ArrowUp';
2517
+ for (let i = 0; i < Math.abs(hops); i++) {
2518
+ await callExtension('qa_press', { tabId, key: arrow, ref }, 15_000).catch(() => {});
2118
2519
  await new Promise((r) => setTimeout(r, 130));
2119
2520
  }
2120
2521
  await callExtension('qa_press', { tabId, key: 'Enter', ref }, 15_000).catch(() => {});
2121
2522
  await new Promise((r) => setTimeout(r, 380));
2122
2523
  const after = await findClickable(tabId, triggerName, null);
2123
- const reads = String(after.hit?.node?.name || '');
2524
+ /* JUDGE BY WHAT IT SHOWS, NOT ONLY BY WHAT IT IS CALLED — otherwise this verb reports failure on
2525
+ the exact control it was written for. downshift's trigger is
2526
+ getToggleButtonProps({ 'aria-label': 'Signatory role' }), and accessibleName (a11y.ts:119)
2527
+ takes aria-label first, so the name still reads "Signatory role" after a PERFECT pick and `ok`
2528
+ comes back false. The name is KEPT and the rendered text ADDED, never swapped: a cascader
2529
+ really does carry its value in its name. The change is monotone — ok can only go false→true —
2530
+ which is what makes it safe for pickPath's two existing callers; there the practical effect is
2531
+ that a keyboard route which already worked stops being reported as a failure and falling
2532
+ through to the click walk. `shownText` refuses to read an EXPANDED control, so an open menu's
2533
+ own option list can never be mistaken for the trigger's value. */
2534
+ const nameNow = String(after.hit?.node?.name || '');
2535
+ const rendered = after.hit?.node ? await shownText(tabId, after.hit.node) : '';
2536
+ const reads = `${nameNow} ${rendered}`.replace(/\s+/g, ' ').trim();
2124
2537
  const ok = norm(reads).includes(norm(optionText));
2125
2538
  return { ok, via: 'keyboard at the trigger', reads: reads.slice(0, 60), trail };
2126
2539
  }
@@ -3092,7 +3505,15 @@ EVERY control kind by name-and-value: native selects, checkboxes, radios, slider
3092
3505
  the ones that look like they need clicking — Radix/shadcn, MUI, Ant and react-select dropdowns,
3093
3506
  typeahead comboboxes, popup date and time pickers (it opens the popover, waits for the options,
3094
3507
  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:
3508
+ dropdowns and dates is the normal use, not an edge case. When you already know the values, a wizard
3509
+ step is ONE call:
3510
+ qa_fill_json { fields: { …the whole step… }, advance: "Next" }
3511
+ → fills, verifies, presses Next only if every entry landed, and rides the NEXT step's field
3512
+ names back in \`next.fields\` — so the step after this one needs no snapshot of yours either.
3513
+ \`advanced.ok\` is read off the click's DIFF, never off its ok: a Next that stays disabled
3514
+ until the step validates accepts a click and does nothing, and this says so instead of
3515
+ moving on. It is not pressed at all when an entry failed or a key matched nothing.
3516
+ Three calls when you do not know the field names yet, or must pick one control among duplicates:
3096
3517
  qa_snapshot { fields_only: true } → the step's controls, not the app
3097
3518
  qa_type_many { fields: [ …every field of the step… ] } → widgets and all, in order
3098
3519
  qa_click { name: "Next" } → its \`changed\` tells you it advanced
@@ -4037,7 +4458,8 @@ server.registerTool(
4037
4458
  '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
4459
  '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
4460
  '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.' +
4461
+ '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. ' +
4462
+ '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
4463
  DRIVE_NOTE,
4042
4464
  inputSchema: {
4043
4465
  tabId: tabIdArg,
@@ -4053,12 +4475,20 @@ server.registerTool(
4053
4475
  .boolean()
4054
4476
  .optional()
4055
4477
  .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.'),
4478
+ advance: z
4479
+ .string()
4480
+ .optional()
4481
+ .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.'),
4482
+ next: z
4483
+ .boolean()
4484
+ .optional()
4485
+ .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
4486
  },
4057
4487
  },
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');
4488
+ async ({ tabId, fields, within, dry_run, verify, advance, next }, extra) => {
4489
+ const bar = loader(extra, dry_run ? 'matching the dataset to the page' : advance ? 'filling the form, then advancing' : 'filling the form');
4060
4490
  try {
4061
- return asText(await fillByNames(tabId, fields, { dryRun: dry_run, within, verify }));
4491
+ return asText(await fillByNames(tabId, fields, { dryRun: dry_run, within, verify, advance, next }));
4062
4492
  } catch (e) {
4063
4493
  return asError(e);
4064
4494
  } finally {