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.
- package/CHANGELOG.md +75 -0
- package/README.md +64 -15
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +190 -14
- package/dist/ci-run.js +268 -52
- package/dist/cli.js +104 -43
- package/dist/commands.js +3 -2
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1147 -286
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +182 -39
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +136 -18
- package/dist/engine/claims.js +159 -3
- package/dist/engine/collector.js +561 -30
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/design.js +281 -38
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +181 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +501 -47
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +41 -1
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/refresh.js +68 -3
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +241 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +4 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +250 -36
- package/dist/mcp-server.js +567 -57
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- 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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
/**
|
|
806
|
-
|
|
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
|
-
|
|
824
|
-
results: r.findings.map((f) =>
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
:
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
};
|
package/dist/engine/claims.js
CHANGED
|
@@ -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
|
})()`;
|