scenescout 3.15.0 → 3.16.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 (56) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +64 -15
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +190 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +104 -43
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +182 -39
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/tickets.js +671 -0
  47. package/dist/engine/unload.js +3 -2
  48. package/dist/export-run.js +633 -0
  49. package/dist/first-run.js +4 -0
  50. package/dist/installer.js +267 -3
  51. package/dist/intake.js +104 -0
  52. package/dist/login-run.js +250 -36
  53. package/dist/mcp-server.js +567 -57
  54. package/dist/playbook.js +5 -0
  55. package/package.json +6 -3
  56. package/skills/scenescout/SKILL.md +49 -16
package/dist/engine/ci.js CHANGED
@@ -11,9 +11,11 @@
11
11
  import { createHash } from "node:crypto";
12
12
  import path from "node:path";
13
13
  import { BROWSER_ENGINES } from "../browsers.js";
14
+ import { MAX_LANES } from "./brief.js";
14
15
  import { markdownCell } from "./check.js";
15
16
  import { parseLimitFlag } from "./limits.js";
16
17
  import { isWorthALook, redactSecrets } from "./memory.js";
18
+ import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifLocation } from "./sarif.js";
17
19
  // ── options ─────────────────────────────────────────────────────────────────
18
20
  export const PROVIDERS = ["anthropic", "openai"];
19
21
  /** Where each provider's key is read from. Nothing else is: no flag, no file, no input. */
@@ -53,6 +55,14 @@ export const DEDUP_ENV = "SCENESCOUT_DEDUP";
53
55
  export const DEDUP_PROVIDER_ENV = "SCENESCOUT_DEDUP_PROVIDER";
54
56
  export const DEFAULT_CAPS = { turns: 40, tokens: 1_500_000, wallMs: 20 * 60_000 };
55
57
  const CAP_BOUNDS = { turns: [1, 500], tokens: [1_000, 20_000_000], minutes: [1, 360] };
58
+ /**
59
+ * How many model loops explore at once, each in its own browser session and
60
+ * its own part of the app (engine/ci-lanes.ts). 1 is the single loop. The most
61
+ * is scout_lane_brief's, since the split is the same one. Why the default is
62
+ * what it is: docs/benchmark.md, "Unattended runs".
63
+ */
64
+ export const DEFAULT_LANES = 1;
65
+ export const MAX_CI_LANES = MAX_LANES;
56
66
  /** Every option `scenescout ci` accepts; the ci action's inputs are these names (ci-test holds them equal). */
57
67
  export const CI_OPTION_NAMES = [
58
68
  "provider",
@@ -62,6 +72,7 @@ export const CI_OPTION_NAMES = [
62
72
  "max-turns",
63
73
  "max-tokens",
64
74
  "max-minutes",
75
+ "lanes",
65
76
  "price-in",
66
77
  "price-cached-in",
67
78
  "price-out",
@@ -78,6 +89,7 @@ export const CI_OPTION_NAMES = [
78
89
  "show",
79
90
  "compare-url",
80
91
  "dedup",
92
+ "sarif-file-anchor",
81
93
  ];
82
94
  /** The longest --show description: it becomes a line of the model's prompt. */
83
95
  export const MAX_SHOW = 200;
@@ -171,9 +183,13 @@ export function parseCiArgs(args, cwd) {
171
183
  const turns = whole("max-turns", CAP_BOUNDS.turns, DEFAULT_CAPS.turns);
172
184
  const tokens = whole("max-tokens", CAP_BOUNDS.tokens, DEFAULT_CAPS.tokens);
173
185
  const minutes = whole("max-minutes", CAP_BOUNDS.minutes, DEFAULT_CAPS.wallMs / 60_000);
174
- for (const v of [turns, tokens, minutes])
186
+ const lanes = whole("lanes", [1, MAX_CI_LANES], DEFAULT_LANES);
187
+ for (const v of [turns, tokens, minutes, lanes])
175
188
  if (typeof v === "string")
176
189
  return { ok: false, error: v };
190
+ // The lanes share the run's turns rather than getting a cap each: fewer turns than lanes would leave a lane none.
191
+ if (lanes > turns)
192
+ return { ok: false, error: `--lanes ${lanes} needs --max-turns of at least ${lanes}: the lanes share the run's turns, and each needs one` };
177
193
  const price = {};
178
194
  for (const [flag, field] of [
179
195
  ["price-in", "input"],
@@ -218,6 +234,8 @@ export function parseCiArgs(args, cwd) {
218
234
  return { ok: false, error: `--show is at most ${MAX_SHOW} characters` };
219
235
  if (flags.has("show") && !show)
220
236
  return { ok: false, error: '--show needs a few words describing the element, e.g. --show "the Save button"' };
237
+ if (show && lanes > 1)
238
+ return { ok: false, error: "--lanes splits an exploration between model loops, and --show explores nothing: give one or the other" };
221
239
  let compareUrl;
222
240
  if (flags.has("compare-url")) {
223
241
  if (!show)
@@ -244,6 +262,9 @@ export function parseCiArgs(args, cwd) {
244
262
  const dedup = flags.get("dedup") ?? DEFAULT_CI_DEDUP;
245
263
  if (!DEDUP_MODES.includes(dedup))
246
264
  return { ok: false, error: `--dedup must be one of ${DEDUP_MODES.join(", ")}` };
265
+ const anchor = flags.has("sarif-file-anchor") ? checkSarifAnchor(flags.get("sarif-file-anchor")) : undefined;
266
+ if (anchor && !anchor.ok)
267
+ return anchor;
247
268
  const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
248
269
  return {
249
270
  ok: true,
@@ -256,6 +277,7 @@ export function parseCiArgs(args, cwd) {
256
277
  ...(effort ? { effort } : {}),
257
278
  ...(baseUrl ? { baseUrl } : {}),
258
279
  caps: { turns: turns, tokens: tokens, wallMs: minutes * 60_000 },
280
+ lanes: lanes,
259
281
  ...(Object.keys(price).length > 0 ? { price } : {}),
260
282
  mode: mode,
261
283
  level: level,
@@ -267,9 +289,23 @@ export function parseCiArgs(args, cwd) {
267
289
  ...(actionTimeout.value !== undefined ? { actionTimeoutMs: actionTimeout.value } : {}),
268
290
  ...(navTimeout.value !== undefined ? { navTimeoutMs: navTimeout.value } : {}),
269
291
  dedup: dedup,
292
+ ...(anchor ? { sarifFileAnchor: anchor.value } : {}),
270
293
  },
271
294
  };
272
295
  }
296
+ /**
297
+ * Why scout_attach did not leave a session the run can use, or null when it
298
+ * did: an error result, or a saved sign-in the app no longer accepts (the
299
+ * attach succeeds, and says so on a line of its own).
300
+ */
301
+ export function attachFailure(r) {
302
+ const authFailed = r.text.split("\n").find((l) => l.startsWith("⚠ AUTH FAILED"));
303
+ if (authFailed)
304
+ return authFailed;
305
+ if (r.isError || /^ERROR:/.test(r.text))
306
+ return r.text.replace(/^ERROR:\s*/, "");
307
+ return null;
308
+ }
273
309
  const present = (env, name) => (env[name] ?? "").trim() !== "";
274
310
  /**
275
311
  * Which provider a run uses: the one whose key is set. With both set the
@@ -362,6 +398,12 @@ export function childEnv(env) {
362
398
  out[k] = v;
363
399
  // Nobody watches a CI run: the live view would only hold a port open.
364
400
  out.SCENESCOUT_LIVE = "off";
401
+ // ...and an unattended run opens nothing in a browser, even on a desktop, unless the user set it.
402
+ if (!out.SCENESCOUT_OPEN?.trim())
403
+ out.SCENESCOUT_OPEN = "none";
404
+ // The loop is text-only (toolResultText), so a finding's picture is kept for the report, never sent back. A choice the job made is its own.
405
+ if (!out.SCENESCOUT_EVIDENCE?.trim())
406
+ out.SCENESCOUT_EVIDENCE = "file";
365
407
  return out;
366
408
  }
367
409
  // ── never printing a key ────────────────────────────────────────────────────
@@ -392,7 +434,8 @@ export function addUsage(a, b) {
392
434
  /**
393
435
  * Which cap, if any, stops the run before its next model call. Checked
394
436
  * between turns: one turn's usage is only known after it, so a run can end
395
- * up to one turn over the token cap, and the report says by how much.
437
+ * up to one turn over the token cap (one per lane in a run split into lanes:
438
+ * see Budget), and the report says by how much.
396
439
  */
397
440
  export function capReached(spend, caps, now) {
398
441
  if (now - spend.startedAt >= caps.wallMs)
@@ -407,6 +450,31 @@ export function capReached(spend, caps, now) {
407
450
  export function wallLeftMs(spend, caps, now) {
408
451
  return Math.max(0, caps.wallMs - (now - spend.startedAt));
409
452
  }
453
+ export function newBudget(caps, startedAt) {
454
+ return { caps, startedAt, turns: 0, usage: { ...NO_USAGE }, inFlight: 0 };
455
+ }
456
+ /** What the budget's loops have spent between them. */
457
+ export function budgetSpend(b) {
458
+ return { turns: b.turns, usage: { ...b.usage }, startedAt: b.startedAt };
459
+ }
460
+ /** Take the next turn for one loop, or name the cap that refuses it. */
461
+ export function takeTurn(b, now) {
462
+ const cap = capReached({ turns: b.turns + b.inFlight, usage: b.usage, startedAt: b.startedAt }, b.caps, now);
463
+ if (cap === null)
464
+ b.inFlight += 1;
465
+ return cap;
466
+ }
467
+ /** A taken turn's call has ended: counted with the usage it reported, or given back when it failed. */
468
+ export function settleTurn(b, usage) {
469
+ // A settle with no turn taken would count a call nobody reserved: a bug in the caller, not a state to carry on from.
470
+ if (b.inFlight <= 0)
471
+ throw new Error("settleTurn without a turn taken: every settle must follow a takeTurn that returned null");
472
+ b.inFlight -= 1;
473
+ if (!usage)
474
+ return;
475
+ b.turns += 1;
476
+ b.usage = addUsage(b.usage, usage);
477
+ }
410
478
  export const EXIT_CI = { completed: 0, couldNotRun: 2 };
411
479
  /**
412
480
  * A CI run reports and never gates, so its findings never set the exit code.
@@ -486,7 +554,8 @@ export function usageLine(spend, model, endedAt, override) {
486
554
  * closes itself, so the mode and the target cannot change), scout_session,
487
555
  * scout_playbook (the method is the system prompt), scout_screenshot (the
488
556
  * loop is text-only), scout_resolve (scout_verify records re-tests), and the
489
- * lane tools (one agent, ADR 14).
557
+ * lane tools (a run split into lanes is planned and folded by the run itself,
558
+ * not by a model: engine/ci-lanes.ts, ADR 20).
490
559
  */
491
560
  export const CI_TOOLS = [
492
561
  "scout_scan",
@@ -683,12 +752,15 @@ export function ciSummaryMarkdown(r, secrets = []) {
683
752
  ...(r.capture ? [] : [`| Level | ${r.level} — completion contract ${r.contractMet ? "met" : "not met (the report's gap ledger says what is missing)"} |`]),
684
753
  `| Mode | ${r.mode} |`,
685
754
  `| Model | ${r.provider} ${cell(r.model, secrets)}, effort ${r.effort} |`,
755
+ ...(r.lanes ? [`| Lanes | ${lanesCell(r.lanes, secrets)} |`] : []),
686
756
  ...(r.dedup ? [`| Finding dedup | ${dedupLine(r.dedup)} |`] : []),
687
757
  `| Usage | ${usageLine(r.spend, r.model, r.endedAt, r.price)} |`,
688
758
  ``,
689
759
  ];
690
760
  if (r.capture)
691
761
  lines.push(...captureSummaryLines(r.capture, secrets));
762
+ if (r.lanes && r.lanes.sessions.length > 0)
763
+ lines.push(...laneSummaryLines(r.lanes, secrets));
692
764
  if (defects.length > 0) {
693
765
  lines.push(`| Severity | Category | Finding | Page |`, `|---|---|---|---|`);
694
766
  for (const f of defects.slice(0, 50))
@@ -707,6 +779,22 @@ export function ciSummaryMarkdown(r, secrets = []) {
707
779
  lines.push(r.capture ? `The pictures are in shots/.` : `The full report, with repro steps and the gap ledger, is report.md.`, ``);
708
780
  return lines.join("\n");
709
781
  }
782
+ function lanesCell(l, secrets) {
783
+ if (l.oneLoop)
784
+ return `${l.asked} asked; explored in one loop: ${cell(l.oneLoop, secrets)}`;
785
+ const ran = l.sessions.filter((s) => s.attached).length;
786
+ const planned = l.sessions.length;
787
+ return (`${ran} of ${l.asked} asked ran at once, sharing the caps below` +
788
+ (planned < l.asked ? `; the app split into ${planned}` : "") +
789
+ (ran < planned ? `; ${planned - ran} could not attach` : ""));
790
+ }
791
+ function laneSummaryLines(l, secrets) {
792
+ const out = [`| Lane | Owns | Routes | Turns | Tokens | Ended |`, `|---|---|---:|---:|---:|---|`];
793
+ for (const s of l.sessions)
794
+ out.push(`| ${cell(s.session, secrets)} | ${cell(s.modules.join(", "), secrets)} | ${s.routes} | ${s.turns} | ${n(s.usage.input + s.usage.output)} | ${s.stop}${s.stopDetail ? `: ${cell(s.stopDetail, secrets)}` : ""} |`);
795
+ out.push(``);
796
+ return out;
797
+ }
710
798
  function captureSummaryLines(c, secrets) {
711
799
  const out = [`**Asked to show:** ${cell(c.what, secrets)}`, ``];
712
800
  if (c.status !== "captured")
@@ -781,6 +869,28 @@ export function ciSummaryJson(r, version, secrets = []) {
781
869
  ...(isWorthALook(f) ? { tier: "worth-a-look", convention: clean(f.convention ?? "") } : {}),
782
870
  })),
783
871
  ...(r.capture ? { capture: cleanCapture(r.capture, clean) } : {}),
872
+ ...(r.lanes ? { lanes: lanesJson(r.lanes, clean) } : {}),
873
+ };
874
+ }
875
+ /** The lanes as ci.json holds them. Module paths and lane names come from the app's routes: redacted like the rest. */
876
+ function lanesJson(l, clean) {
877
+ return {
878
+ asked: l.asked,
879
+ planned: l.sessions.length,
880
+ ran: l.sessions.filter((s) => s.attached).length,
881
+ ...(l.oneLoop ? { oneLoop: clean(l.oneLoop) } : {}),
882
+ sessions: l.sessions.map((s) => ({
883
+ session: clean(s.session),
884
+ modules: s.modules.map(clean),
885
+ routes: s.routes,
886
+ attached: s.attached,
887
+ stop: s.stop,
888
+ ...(s.stopDetail ? { detail: clean(s.stopDetail) } : {}),
889
+ turns: s.turns,
890
+ inputTokens: s.usage.input,
891
+ cachedInputTokens: s.usage.cachedInput,
892
+ outputTokens: s.usage.output,
893
+ })),
784
894
  };
785
895
  }
786
896
  /** The capture as ci.json holds it: the words a person or a model wrote, and the URLs, passed through the same redaction as the rest. */
@@ -802,8 +912,13 @@ function appOrigin(url) {
802
912
  return url;
803
913
  }
804
914
  }
805
- /** This run's findings as SARIF 2.1.0: one rule per category, the finding's id as its fingerprint. */
806
- export function ciSarif(r, version, secrets = []) {
915
+ /**
916
+ * This run's findings as SARIF 2.1.0: one rule per category, the finding's id
917
+ * as its fingerprint. Code scanning keeps only results located in a repository
918
+ * file, so each points at the anchor (sarif.ts) and carries its page as a
919
+ * logical location, in `properties.route` and in its message.
920
+ */
921
+ export function ciSarif(r, version, secrets = [], anchor = SARIF_ANCHOR_FALLBACKS[0]) {
807
922
  const clean = (s) => redactKeys(redactSecrets(s), secrets);
808
923
  const categories = [...new Set(r.findings.map((f) => f.category))].sort();
809
924
  return {
@@ -820,19 +935,22 @@ export function ciSarif(r, version, secrets = []) {
820
935
  },
821
936
  },
822
937
  invocations: [{ executionSuccessful: r.stop !== "provider-error" && r.stop !== "could-not-start", properties: { stop: r.stop } }],
823
- originalUriBaseIds: { APP: { uri: `${appOrigin(r.url)}/` } },
824
- results: r.findings.map((f) => ({
825
- ruleId: `finding/${f.category}`,
826
- level: isWorthALook(f) ? "note" : SARIF_LEVEL[f.severity],
827
- message: {
828
- text: isWorthALook(f)
829
- ? `Worth a look — ${clean(f.title)}. A defect only if your project uses ${clean(f.convention ?? "a convention")}.`
830
- : `[${f.severity}] ${clean(f.title)}${f.evidence ? ` — ${clean(f.evidence)}` : ""}`,
831
- },
832
- locations: [{ physicalLocation: { artifactLocation: { uri: clean(pathOf(f.url)).replace(/^\//, ""), uriBaseId: "APP" } } }],
833
- partialFingerprints: { "scenescoutFinding/v1": createHash("sha256").update(f.id).digest("hex").slice(0, 32) },
834
- ...(isWorthALook(f) ? { properties: { tier: "worth-a-look", convention: clean(f.convention ?? "") } } : {}),
835
- })),
938
+ properties: { app: clean(appOrigin(r.url)) },
939
+ results: r.findings.map((f) => {
940
+ const route = clean(pathOf(f.url));
941
+ return {
942
+ ruleId: `finding/${f.category}`,
943
+ level: isWorthALook(f) ? "note" : SARIF_LEVEL[f.severity],
944
+ message: {
945
+ text: isWorthALook(f)
946
+ ? `Worth a look — ${clean(f.title)} — on ${route}. A defect only if your project uses ${clean(f.convention ?? "a convention")}.`
947
+ : `[${f.severity}] ${clean(f.title)}${f.evidence ? ` — ${clean(f.evidence)}` : ""} — on ${route}`,
948
+ },
949
+ locations: [sarifLocation(anchor, route)],
950
+ partialFingerprints: { "scenescoutFinding/v1": createHash("sha256").update(f.id).digest("hex").slice(0, 32) },
951
+ properties: { route, ...(isWorthALook(f) ? { tier: "worth-a-look", convention: clean(f.convention ?? "") } : {}) },
952
+ };
953
+ }),
836
954
  },
837
955
  ],
838
956
  };
@@ -99,7 +99,28 @@ const REFUSED_RE = /\b(?:refused|rejected|blocked)\b/i;
99
99
  * an error monitor, a heartbeat. Refusing one says nothing about whether the
100
100
  * change the user made was kept, so it is never paired with a success claim.
101
101
  */
102
- export const INFRASTRUCTURE_WRITE_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/collect\b|\/logs?\b|\/metrics\b/i;
102
+ export const INFRASTRUCTURE_WRITE_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/envelope\b|client[-_]?errors?\b|\/collect\b|\/logs?\b|\/metrics\b/i;
103
+ /**
104
+ * Whether a request is one the action being judged did not send (see
105
+ * `WatchedRequest.background`).
106
+ *
107
+ * Once a click has started loading a new document, what follows is not the
108
+ * click's own write. Requests the old page sends as it is left (a preference
109
+ * save on pagehide) belong to that page, and requests the new page sends as it
110
+ * loads (an error monitor, a visit beacon) are sent with no input on it yet.
111
+ * Neither is answered by anything on screen, and the new page's text is all
112
+ * new against the old page's, so paired they read every static "Completed" as
113
+ * a false success. The click's own writes come before the navigation starts:
114
+ * a script's save awaited before it moves the page, or the navigation itself
115
+ * when it is a native form post.
116
+ */
117
+ export function isBackgroundRequest(t) {
118
+ if (t.inputSince === null || t.inputSince < t.actionStartedAt || t.started < t.inputSince)
119
+ return true;
120
+ if (t.isDocumentLoad)
121
+ return false;
122
+ return t.documentLoadSince !== null && t.started > t.documentLoadSince;
123
+ }
103
124
  /** Whether a piece of announced text explains a refusal. */
104
125
  export function isRefusalNotice(text) {
105
126
  const trimmed = text.trim();
@@ -167,7 +188,7 @@ function admitsInAnnouncement(text) {
167
188
  * "Published", a heading, a row from earlier. Only what the action put up can
168
189
  * contradict what the action's write met. Without it, every text counts.
169
190
  */
170
- export function findContradictions(requests, page, before) {
191
+ export function findContradictions(requests, page, before, acted) {
171
192
  const refused = requests.filter(isRefused);
172
193
  if (refused.length === 0)
173
194
  return [];
@@ -233,8 +254,140 @@ export function findContradictions(requests, page, before) {
233
254
  });
234
255
  }
235
256
  }
257
+ // No success word, but the control or a counter beside it shows the change
258
+ // as kept: an optimistic UI telling the same falsehood without words.
259
+ // Medium: the page never said "saved", and a reader may still notice a
260
+ // reload putting the old value back.
261
+ const change = acted && writes.length > 0 && kept.length === 0 && successAt < 0 ? keptChange(acted.before, acted.after) : null;
262
+ if (change) {
263
+ const worst = writes[0];
264
+ out.push({
265
+ kind: "false_success",
266
+ severity: "medium",
267
+ detail: `${say(worst)} was refused, and the page shows the change as kept (${change}) with no error. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
268
+ evidence: `false-success-kept ${say(worst)}`,
269
+ });
270
+ }
236
271
  return out;
237
272
  }
273
+ /** A counter: "1/3", "2 of 5", "40%". Not a date ("10/02/2026") nor a part of a longer number. */
274
+ export const COUNTER_RE = /(?<![\d/.,:])(?:\d{1,4} ?(?:\/|of) ?\d{1,4}|\d{1,3} ?%)(?![\d/.,:])/i;
275
+ /** The containers a counter must share with the control to count as its counter. */
276
+ const CONTROL_CONTAINER_SEL = 'dialog, [role="dialog"], [role="alertdialog"], fieldset, form, [role="group"], [role="radiogroup"], [role="toolbar"], [role="region"], section, aside, [role="complementary"]';
277
+ /** Most counters read for one control. */
278
+ export const MAX_COUNTERS = 20;
279
+ /**
280
+ * Page-side reader of an acted-on control: `(${ACTED_CONTROL_SRC})(node)`
281
+ * returns an ActedControl, or null for no node. A label stands for the box it
282
+ * labels. Shipped as a string for the same reason CLAIM_SCAN_SCRIPT is.
283
+ */
284
+ export const ACTED_CONTROL_SRC = `(node) => {
285
+ if (!node) return null;
286
+ const visible = ${VISIBLE_SRC};
287
+ const box = (n) => n && n.tagName === "INPUT" && (n.type === "checkbox" || n.type === "radio");
288
+ const control = node.control || (box(node) ? node : node.querySelector && node.querySelector("input[type=checkbox], input[type=radio]")) || node;
289
+ const flag = (v) => (v === "true" ? true : v === "false" ? false : null);
290
+ const role = control.getAttribute("role") || (box(control) ? control.type : control.tagName.toLowerCase());
291
+ const counter = ${COUNTER_RE};
292
+ const counters = [];
293
+ const add = (t) => {
294
+ const text = (t || "").replace(/\\s+/g, " ").trim();
295
+ if (text && text.length <= 60 && counter.test(text) && !counters.includes(text) && counters.length < ${MAX_COUNTERS}) counters.push(text);
296
+ };
297
+ const near = control.closest(${JSON.stringify(CONTROL_CONTAINER_SEL)}) || (control.parentElement && control.parentElement.parentElement);
298
+ if (near) {
299
+ const walker = document.createTreeWalker(near, NodeFilter.SHOW_ELEMENT);
300
+ for (let el = near; el && counters.length < ${MAX_COUNTERS}; el = walker.nextNode()) {
301
+ let own = "";
302
+ for (const child of el.childNodes) if (child.nodeType === 3) own += child.nodeValue;
303
+ if (own.trim() && visible(el)) add(own);
304
+ }
305
+ }
306
+ for (const region of document.querySelectorAll("[role~='status'], [role~='alert'], [aria-live]:not([aria-live='off']), output")) {
307
+ if (visible(region)) add(region.innerText);
308
+ }
309
+ return {
310
+ role,
311
+ checked: box(control) ? control.checked : flag(control.getAttribute("aria-checked")),
312
+ pressed: flag(control.getAttribute("aria-pressed")),
313
+ selected: flag(control.getAttribute("aria-selected")),
314
+ counters,
315
+ };
316
+ }`;
317
+ /**
318
+ * How the acted-on control shows its change as kept, or null when it does not.
319
+ *
320
+ * A checked, pressed or selected state that moved is the change itself (a
321
+ * tab's selection is not: choosing a tab is moving around, not a change the
322
+ * user asked to keep). A counter beside it that moved ("0/3" to "1/3
323
+ * completed") counts it as done. A counter that merely appeared does not: a
324
+ * step that opens with "Step 1 of 3" counts nothing yet.
325
+ */
326
+ export function keptChange(before, after) {
327
+ for (const state of ["checked", "pressed", "selected"]) {
328
+ if (state === "selected" && after.role === "tab")
329
+ continue;
330
+ const was = before[state];
331
+ const now = after[state];
332
+ if (was !== null && now !== null && was !== now)
333
+ return `the control is now ${now ? state : `not ${state}`}`;
334
+ }
335
+ const gone = before.counters.filter((c) => !after.counters.includes(c));
336
+ const fresh = after.counters.filter((c) => !before.counters.includes(c));
337
+ if (gone.length > 0 && fresh.length > 0)
338
+ return `${JSON.stringify(gone[0])} became ${JSON.stringify(fresh[0])}`;
339
+ return null;
340
+ }
341
+ /**
342
+ * Whether a submit-shaped click that sent no request was answered on the page
343
+ * anyway, judged from the page as the click began and as it settled.
344
+ *
345
+ * - A dialog opened: a step (a confirmation), not a submit.
346
+ * - A field became invalid, or the page announced something new that is not
347
+ * a success claim: client-side validation said what was wrong, and no
348
+ * request was expected. A new "Saved!" is the opposite, the very case the
349
+ * silent-submit note is for, so it never counts.
350
+ *
351
+ * Null when either reading is missing or nothing visible answered.
352
+ */
353
+ export function quietAnswer(before, after) {
354
+ if (!before || !after)
355
+ return null;
356
+ if ((after.dialogs ?? 0) > (before.dialogs ?? 0))
357
+ return "dialog";
358
+ if ((after.invalid ?? 0) > (before.invalid ?? 0))
359
+ return "validation";
360
+ const said = new Set(before.announced ?? []);
361
+ if ((after.announced ?? []).some((text) => !said.has(text) && classify(text) !== "success"))
362
+ return "validation";
363
+ return null;
364
+ }
365
+ /**
366
+ * Whether a piece of announced text (an alert's or a live region's name, as
367
+ * the snapshot lists it) was not on the page at `baseline`. The name may be a
368
+ * region's sentences run together and cut short, so it is matched by prefix
369
+ * either way against the baseline's texts and announced sentences. False
370
+ * when there is no baseline: nothing says when the text arrived.
371
+ */
372
+ export function saidSince(text, baseline) {
373
+ if (!baseline)
374
+ return false;
375
+ const norm = (t) => t.replace(/\s+/g, " ").trim();
376
+ const said = norm(text);
377
+ if (!said)
378
+ return false;
379
+ for (const earlier of [...baseline.texts, ...(baseline.announced ?? [])]) {
380
+ const e = norm(earlier);
381
+ if (!e)
382
+ continue;
383
+ if (e === said)
384
+ return false;
385
+ const shorter = Math.min(e.length, said.length);
386
+ if (shorter >= 20 && (e.startsWith(said) || said.startsWith(e)))
387
+ return false;
388
+ }
389
+ return true;
390
+ }
238
391
  /** Most announced regions read from one page. */
239
392
  export const MAX_ANNOUNCED = 40;
240
393
  /** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
@@ -310,6 +463,9 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
310
463
  // confirmation a router cancelled a route change for (oracles.ts).
311
464
  let dialogs = 0;
312
465
  for (const d of document.querySelectorAll(${JSON.stringify(DIALOG_SEL)})) if (visible(d)) dialogs += 1;
466
+ // Fields the page marked invalid, so a submit answered by validation is not called silent.
467
+ let invalid = 0;
468
+ for (const f of document.querySelectorAll('[aria-invalid="true"]')) if (visible(f)) invalid += 1;
313
469
 
314
- return { texts, announced, emptyLists, dialogs };
470
+ return { texts, announced, emptyLists, dialogs, invalid };
315
471
  })()`;