scenescout 3.23.3 → 3.23.4
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 +11 -0
- package/README.md +20 -3
- package/dist/browsers.js +7 -6
- package/dist/ci-run.js +3 -2
- package/dist/cli.js +2 -1
- package/dist/clients.js +3 -3
- package/dist/code-routes.js +2 -2
- package/dist/commands.js +4 -4
- package/dist/engine/args.js +21 -0
- package/dist/engine/bench.js +1 -1
- package/dist/engine/brief.js +1 -1
- package/dist/engine/browser.js +59 -15
- package/dist/engine/capture.js +4 -4
- package/dist/engine/check-replay.js +82 -8
- package/dist/engine/check-report.js +8 -8
- package/dist/engine/check.js +15 -15
- package/dist/engine/ci.js +20 -20
- package/dist/engine/claims.js +4 -4
- package/dist/engine/collector.js +7 -7
- package/dist/engine/crawl.js +1 -1
- package/dist/engine/dedup.js +4 -4
- package/dist/engine/design.js +7 -7
- package/dist/engine/expiry.js +1 -1
- package/dist/engine/export.js +7 -7
- package/dist/engine/flow.js +10 -10
- package/dist/engine/forms.js +1 -1
- package/dist/engine/from-run.js +6 -6
- package/dist/engine/injection.js +1 -1
- package/dist/engine/lane.js +1 -1
- package/dist/engine/limits.js +2 -2
- package/dist/engine/live.js +30 -0
- package/dist/engine/memory.js +24 -7
- package/dist/engine/open.js +1 -1
- package/dist/engine/oracles.js +2 -2
- package/dist/engine/plain.js +1 -1
- package/dist/engine/png.js +2 -2
- package/dist/engine/policy.js +2 -2
- package/dist/engine/postmessage.js +7 -7
- package/dist/engine/project-folder.js +1 -1
- package/dist/engine/provider.js +1 -1
- package/dist/engine/replay.js +3 -1
- package/dist/engine/report.js +3 -3
- package/dist/engine/sarif.js +2 -2
- package/dist/engine/script-literal.js +11 -0
- package/dist/engine/sensitive.js +254 -0
- package/dist/engine/signed-in.js +2 -2
- package/dist/engine/status-pane-page.js +4 -3
- package/dist/engine/task.js +1 -1
- package/dist/engine/teardown.js +2 -0
- package/dist/engine/tickets.js +6 -6
- package/dist/engine/verify.js +3 -3
- package/dist/export-run.js +1 -1
- package/dist/installer.js +4 -4
- package/dist/login-run.js +2 -2
- package/dist/mcp-server.js +6 -9
- package/dist/playbook.js +1 -1
- package/package.json +2 -2
package/dist/engine/check.js
CHANGED
|
@@ -13,13 +13,14 @@
|
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { BROWSER_ENGINES } from "../browsers.js";
|
|
15
15
|
import { BASELINE_MODES, baselineEvidence, baselineFingerprint, countStatuses, describeChange, parseThreshold, VISUAL_RULE, } from "./baseline.js";
|
|
16
|
+
import { readFlagValue } from "./args.js";
|
|
16
17
|
import { isNonPageResource } from "./crawl.js";
|
|
17
18
|
import { flowStepEvidence } from "./flow.js";
|
|
18
19
|
import { parseLimitFlag } from "./limits.js";
|
|
19
20
|
import { redactRoute, redactSecrets } from "./memory.js";
|
|
20
21
|
import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifFileFor, sarifLocation } from "./sarif.js";
|
|
21
22
|
import { httpErrorDetail, httpStatusOf } from "./oracles.js";
|
|
22
|
-
|
|
23
|
+
const CHECK_SEVERITIES = ["high", "medium", "low"];
|
|
23
24
|
export const FAIL_ON = [...CHECK_SEVERITIES, "never"];
|
|
24
25
|
/**
|
|
25
26
|
* The routes that answered as pages, and those that answered as something
|
|
@@ -154,8 +155,8 @@ export const WORTH_A_LOOK_RULES = {
|
|
|
154
155
|
convention: "row actions and other controls that stay in view without a sideways scroll of their container at this viewport width",
|
|
155
156
|
},
|
|
156
157
|
};
|
|
157
|
-
|
|
158
|
-
|
|
158
|
+
const CHECK_RULE_IDS = [...Object.keys(CHECK_RULES), ...Object.keys(WORTH_A_LOOK_RULES)];
|
|
159
|
+
function isWorthALookRule(rule) {
|
|
159
160
|
return Object.prototype.hasOwnProperty.call(WORTH_A_LOOK_RULES, rule);
|
|
160
161
|
}
|
|
161
162
|
export const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
@@ -371,7 +372,7 @@ export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignore
|
|
|
371
372
|
* Fewest controls a start page must show for a check that measured nothing
|
|
372
373
|
* else to count as having measured the app (unmeasuredReason).
|
|
373
374
|
*/
|
|
374
|
-
|
|
375
|
+
const START_PAGE_MIN_CONTROLS = 2;
|
|
375
376
|
/**
|
|
376
377
|
* Why a check has nothing to give a verdict on, or null when it measured the
|
|
377
378
|
* app. Neither case is a pass: a gate that goes green on a login page or a
|
|
@@ -470,9 +471,9 @@ export const EXIT = { pass: 0, gateFailed: 1, error: 2 };
|
|
|
470
471
|
* pages. Each default is the one that does the least harm on an unfamiliar
|
|
471
472
|
* project: it never writes and never silently hides a result (ADR 12).
|
|
472
473
|
*/
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
474
|
+
const FLOW_WRITES = ["never", "allow"];
|
|
475
|
+
const ON_REFUSED_STEP = ["report", "stop"];
|
|
476
|
+
const GATE_RETESTS = ["never", "high", "all"];
|
|
476
477
|
export const DEFAULT_SETTINGS = { flowWrites: "never", onRefusedStep: "report", gateRetests: "high", retest: true };
|
|
477
478
|
/**
|
|
478
479
|
* Every `--option` `scenescout check` accepts. The GitHub Action at the
|
|
@@ -512,7 +513,7 @@ const SWITCH_OFF = ["off", "false", "0"];
|
|
|
512
513
|
export const MAX_CHECK_ROUTES = 150;
|
|
513
514
|
/** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
|
|
514
515
|
export const MAX_DISCOVERY_ROUNDS = 6;
|
|
515
|
-
|
|
516
|
+
const DEFAULT_CHECK_ROUTES = 50;
|
|
516
517
|
/** A path given on the command line, resolved from the directory the command runs in; the same on every platform. */
|
|
517
518
|
export function resolveArgPath(cwd, p) {
|
|
518
519
|
return p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`;
|
|
@@ -537,12 +538,11 @@ export function parseCheckArgs(args, cwd) {
|
|
|
537
538
|
flags.set(name, next !== undefined && [...SWITCH_ON, ...SWITCH_OFF].includes(next) ? next : "on");
|
|
538
539
|
continue;
|
|
539
540
|
}
|
|
540
|
-
const
|
|
541
|
-
if (
|
|
542
|
-
return
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
flags.set(name, value);
|
|
541
|
+
const read = readFlagValue(args, i);
|
|
542
|
+
if (!read.ok)
|
|
543
|
+
return read;
|
|
544
|
+
i = read.last;
|
|
545
|
+
flags.set(name, read.value);
|
|
546
546
|
}
|
|
547
547
|
const known = new Set(CHECK_OPTION_NAMES);
|
|
548
548
|
for (const name of flags.keys())
|
|
@@ -1038,7 +1038,7 @@ export function needsSignInText(count, cover, noun = "route") {
|
|
|
1038
1038
|
return `${count} ${noun}${count === 1 ? " needs" : "s need"} sign-in; give a role to cover them: ${cover}.`;
|
|
1039
1039
|
}
|
|
1040
1040
|
/** The routes that sent a check with no session to sign-in, as their own section; nothing when there were none. */
|
|
1041
|
-
|
|
1041
|
+
function needsSignInSection(result) {
|
|
1042
1042
|
const routes = result.needsSignIn ?? [];
|
|
1043
1043
|
if (routes.length === 0)
|
|
1044
1044
|
return [];
|
package/dist/engine/ci.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { createHash } from "node:crypto";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
import { BROWSER_ENGINES } from "../browsers.js";
|
|
14
|
+
import { readFlagValue } from "./args.js";
|
|
14
15
|
import { MAX_LANES } from "./brief.js";
|
|
15
16
|
import { markdownCell } from "./check.js";
|
|
16
17
|
import { parseLimitFlag } from "./limits.js";
|
|
@@ -18,18 +19,18 @@ import { isWorthALook, redactSecrets } from "./memory.js";
|
|
|
18
19
|
import { SARIF_ANCHOR_FALLBACKS, checkSarifAnchor, sarifLocation } from "./sarif.js";
|
|
19
20
|
import { fromRunLine, resolveFromRun } from "./from-run.js";
|
|
20
21
|
// ── options ─────────────────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
+
const PROVIDERS = ["anthropic", "openai"];
|
|
22
23
|
/** Where each provider's key is read from. Nothing else is: no flag, no file, no input. */
|
|
23
24
|
export const KEY_ENV = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY" };
|
|
24
|
-
|
|
25
|
+
const DEFAULT_MODEL = { anthropic: "claude-sonnet-5", openai: "gpt-6-luna" };
|
|
25
26
|
/** The API root each request path is joined to. `--base-url` replaces it, for a compatible endpoint. */
|
|
26
|
-
|
|
27
|
+
const DEFAULT_BASE_URL = { anthropic: "https://api.anthropic.com/v1", openai: "https://api.openai.com/v1" };
|
|
27
28
|
/** Reasoning effort each API accepts. The Messages API has no "none": thinking is lowered, not switched off. */
|
|
28
|
-
|
|
29
|
+
const EFFORTS = {
|
|
29
30
|
anthropic: ["low", "medium", "high", "xhigh", "max"],
|
|
30
31
|
openai: ["none", "low", "medium", "high", "xhigh", "max"],
|
|
31
32
|
};
|
|
32
|
-
|
|
33
|
+
const DEFAULT_EFFORT = "low";
|
|
33
34
|
/**
|
|
34
35
|
* The write policies a CI run may use. `destructive` lets the model send any
|
|
35
36
|
* request, deletes of records it did not create included, so it is taken only
|
|
@@ -37,8 +38,8 @@ export const DEFAULT_EFFORT = "low";
|
|
|
37
38
|
* workflow or typed by an agent, never enables it. What CI may do is the
|
|
38
39
|
* developer's decision; the default stays read-only (ADR 14).
|
|
39
40
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
const CI_MODES = ["observe", "read-only", "safe-write", "destructive"];
|
|
42
|
+
const CI_LEVELS = ["minimal", "medium", "extensive"];
|
|
42
43
|
/**
|
|
43
44
|
* How a filed finding is told apart from one already stored. `rule`: the
|
|
44
45
|
* store's rule alone (memory.ts isDuplicateFinding). `judge`: the rule first,
|
|
@@ -49,11 +50,11 @@ export const CI_LEVELS = ["minimal", "medium", "extensive"];
|
|
|
49
50
|
* judges only when asked to (DEDUP_ENV or scout_attach {dedup}).
|
|
50
51
|
*/
|
|
51
52
|
export const DEDUP_MODES = ["rule", "judge"];
|
|
52
|
-
|
|
53
|
+
const DEFAULT_CI_DEDUP = "judge";
|
|
53
54
|
/** Turns the dedup judge on for the MCP server: `rule` (the default) or `judge`. An attach's `dedup` wins over it. */
|
|
54
55
|
export const DEDUP_ENV = "SCENESCOUT_DEDUP";
|
|
55
56
|
/** Which provider the MCP server's dedup judge uses when both keys are in its environment. */
|
|
56
|
-
|
|
57
|
+
const DEDUP_PROVIDER_ENV = "SCENESCOUT_DEDUP_PROVIDER";
|
|
57
58
|
/** Why these are the defaults: docs/benchmark.md, "Choosing the defaults (issue 419)". */
|
|
58
59
|
export const DEFAULT_CAPS = { turns: 80, tokens: 3_000_000, wallMs: 20 * 60_000 };
|
|
59
60
|
const CAP_BOUNDS = { turns: [1, 500], tokens: [1_000, 20_000_000], minutes: [1, 360] };
|
|
@@ -66,7 +67,7 @@ const CAP_BOUNDS = { turns: [1, 500], tokens: [1_000, 20_000_000], minutes: [1,
|
|
|
66
67
|
* lanes) runs as one loop rather than failing; --lanes given explicitly is
|
|
67
68
|
* held to those rules.
|
|
68
69
|
*/
|
|
69
|
-
|
|
70
|
+
const DEFAULT_LANES = 2;
|
|
70
71
|
export const MAX_CI_LANES = MAX_LANES;
|
|
71
72
|
/** Every option `scenescout ci` accepts; the ci action's inputs are these names (ci-test holds them equal). */
|
|
72
73
|
export const CI_OPTION_NAMES = [
|
|
@@ -100,7 +101,7 @@ export const CI_OPTION_NAMES = [
|
|
|
100
101
|
"sarif-file-anchor",
|
|
101
102
|
];
|
|
102
103
|
/** The longest --show description: it becomes a line of the model's prompt. */
|
|
103
|
-
|
|
104
|
+
const MAX_SHOW = 200;
|
|
104
105
|
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
105
106
|
/** A base URL a key may be sent to: https, or plain http only to this machine. */
|
|
106
107
|
export function checkBaseUrl(raw) {
|
|
@@ -136,12 +137,11 @@ export function parseCiArgs(args, cwd, env = {}) {
|
|
|
136
137
|
flags.set(name, "true");
|
|
137
138
|
continue;
|
|
138
139
|
}
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
flags.set(name, value);
|
|
140
|
+
const read = readFlagValue(args, i);
|
|
141
|
+
if (!read.ok)
|
|
142
|
+
return read;
|
|
143
|
+
i = read.last;
|
|
144
|
+
flags.set(name, read.value);
|
|
145
145
|
}
|
|
146
146
|
const known = new Set(CI_OPTION_NAMES);
|
|
147
147
|
for (const name of flags.keys()) {
|
|
@@ -522,7 +522,7 @@ export function describeStop(reason, caps, detail) {
|
|
|
522
522
|
return `could not start${detail ? `: ${detail}` : ""}`;
|
|
523
523
|
}
|
|
524
524
|
}
|
|
525
|
-
|
|
525
|
+
const PRICES = {
|
|
526
526
|
"gpt-6-luna": { input: 0.1, cachedInput: 0.01, output: 0.5 },
|
|
527
527
|
"gpt-5.6-luna": { input: 0.2, cachedInput: 0.02, output: 1.2 },
|
|
528
528
|
"claude-sonnet-5": { input: 2, cachedInput: 0.2, cacheWrite: 2.5, output: 10 },
|
|
@@ -666,7 +666,7 @@ export function guardToolArgs(name, args, projectDir) {
|
|
|
666
666
|
return { ok: true, args: { ...args, projectPath: path.resolve(projectDir) } };
|
|
667
667
|
}
|
|
668
668
|
/** The longest tool result handed back to the model, in characters. The report keeps everything; this only bounds the context. */
|
|
669
|
-
|
|
669
|
+
const TOOL_RESULT_MAX_CHARS = 16_000;
|
|
670
670
|
/** One tool result as text: images named rather than sent, and the length bounded. */
|
|
671
671
|
export function toolResultText(content, max = TOOL_RESULT_MAX_CHARS) {
|
|
672
672
|
const parts = (content ?? []).map((c) => (c.type === "text" ? (c.text ?? "") : `[${c.type} omitted: this run is text-only]`));
|
|
@@ -737,7 +737,7 @@ export function findingsThisRun(before, after) {
|
|
|
737
737
|
return after.filter((f) => f.status !== "resolved" && (!runsBefore.has(f.id) || f.runs > (runsBefore.get(f.id) ?? 0)));
|
|
738
738
|
}
|
|
739
739
|
/** One line on how a run deduplicated, for the summary. */
|
|
740
|
-
|
|
740
|
+
function dedupLine(d) {
|
|
741
741
|
if (d.by === "rule")
|
|
742
742
|
return "the rule alone";
|
|
743
743
|
const tokens = d.usage.input + d.usage.output;
|
package/dist/engine/claims.js
CHANGED
|
@@ -282,7 +282,7 @@ function pathNames(req, subject) {
|
|
|
282
282
|
* sentence naming what they fetched is a genuinely empty section rather than
|
|
283
283
|
* a refusal hidden behind an empty state.
|
|
284
284
|
*/
|
|
285
|
-
|
|
285
|
+
function pairEmptyState(text, refusedReads, loaded) {
|
|
286
286
|
const subject = emptyStateSubject(text);
|
|
287
287
|
if (subject.length === 0)
|
|
288
288
|
return { certain: true, read: refusedReads[0] };
|
|
@@ -446,7 +446,7 @@ export const COUNTER_RE = /(?<![\d/.,:])(?:\d{1,4} ?(?:\/|of) ?\d{1,4}|\d{1,3} ?
|
|
|
446
446
|
/** The containers a counter must share with the control to count as its counter. */
|
|
447
447
|
const CONTROL_CONTAINER_SEL = 'dialog, [role="dialog"], [role="alertdialog"], fieldset, form, [role="group"], [role="radiogroup"], [role="toolbar"], [role="region"], section, aside, [role="complementary"]';
|
|
448
448
|
/** Most counters read for one control. */
|
|
449
|
-
|
|
449
|
+
const MAX_COUNTERS = 20;
|
|
450
450
|
/**
|
|
451
451
|
* Page-side reader of an acted-on control: `(${ACTED_CONTROL_SRC})(node)`
|
|
452
452
|
* returns an ActedControl, or null for no node. A label stands for the box it
|
|
@@ -560,9 +560,9 @@ export function saidSince(text, baseline) {
|
|
|
560
560
|
return true;
|
|
561
561
|
}
|
|
562
562
|
/** Most announced regions read from one page. */
|
|
563
|
-
|
|
563
|
+
const MAX_ANNOUNCED = 40;
|
|
564
564
|
/** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
|
|
565
|
-
|
|
565
|
+
const MAX_CLAIM_TEXTS = 120;
|
|
566
566
|
/**
|
|
567
567
|
* Page-side reader for the two signals `findContradictions` needs. Shipped as
|
|
568
568
|
* a STRING expression for the same reason the collector is: loader transforms
|
package/dist/engine/collector.js
CHANGED
|
@@ -48,7 +48,7 @@ export const XPATH_OF_SRC = `(el) => {
|
|
|
48
48
|
* says nothing. Kept apart from NAME_FACTS_SRC so the order can be
|
|
49
49
|
* table-tested without a browser (contentSays).
|
|
50
50
|
*/
|
|
51
|
-
|
|
51
|
+
const CONTENT_SAYS_SRC = `(d) => {
|
|
52
52
|
if (d.ariaLabel && d.ariaLabel.trim()) return d.ariaLabel;
|
|
53
53
|
if (d.tag === "img") return d.alt && d.alt.trim() ? d.alt : d.title;
|
|
54
54
|
if (d.tag === "svg") return d.svgTitle;
|
|
@@ -75,7 +75,7 @@ export function contentSays(facts) {
|
|
|
75
75
|
* an <img>'s alt and an <svg>'s or role="img"'s aria-label or <title>: it is
|
|
76
76
|
* read so a control renamed since keeps its coverage.
|
|
77
77
|
*/
|
|
78
|
-
|
|
78
|
+
const NAME_FACTS_SRC = `(el) => {
|
|
79
79
|
const tag = el.tagName.toLowerCase();
|
|
80
80
|
const role = el.getAttribute("role") || "";
|
|
81
81
|
const inputType = tag === "input" ? el.type : "";
|
|
@@ -156,7 +156,7 @@ export const NAME_FACTS_SRC = `(el) => {
|
|
|
156
156
|
* - A whitespace-only aria-label ends the name with nothing, as it always has
|
|
157
157
|
* here: the field it hides is reported as unnamed.
|
|
158
158
|
*/
|
|
159
|
-
|
|
159
|
+
const PICK_NAME_SRC = `(f) => {
|
|
160
160
|
const clean = (s) => (s || "").replace(/\\s+/g, " ").trim();
|
|
161
161
|
const named = (s) => ({ name: s.slice(0, 80), from: null });
|
|
162
162
|
const field = f.tag === "input" || f.tag === "textarea" || f.tag === "select";
|
|
@@ -300,9 +300,9 @@ export const FOCUS_MOVES_PROPS = [
|
|
|
300
300
|
/** The most elements a snapshot lists in full; past it, what is left is counted (cutSummary). */
|
|
301
301
|
export const COLLECTOR_CAP = 150;
|
|
302
302
|
/** Pager and "load more" controls listed past the cap, so a lane can still reach the rest of a long list. */
|
|
303
|
-
|
|
303
|
+
const MAX_KEPT_PAST_CAP = 10;
|
|
304
304
|
/** How many elements past the cap are looked at, to keep them or count them, before the rest goes uncounted. */
|
|
305
|
-
|
|
305
|
+
const MAX_SCANNED_PAST_CAP = 2000;
|
|
306
306
|
/**
|
|
307
307
|
* A control that pages a list or loads more of it, by its name: "Next",
|
|
308
308
|
* "Previous page", "Page 3", "»", "Load more", "Show 20 more", "More results".
|
|
@@ -906,7 +906,7 @@ export function cutSummary(cut, groups = 8) {
|
|
|
906
906
|
return named.join(", ");
|
|
907
907
|
}
|
|
908
908
|
/** A key without the ordinal the collector adds to the second and later elements sharing it ("tid:row~3" → "tid:row"). */
|
|
909
|
-
|
|
909
|
+
function keyFamily(key) {
|
|
910
910
|
return key.replace(/~\d+$/, "");
|
|
911
911
|
}
|
|
912
912
|
/**
|
|
@@ -1315,7 +1315,7 @@ export function frameElementKey(baseKey, frame) {
|
|
|
1315
1315
|
return `frame:${where}|${baseKey}`;
|
|
1316
1316
|
}
|
|
1317
1317
|
/** The longest name kept for a control in another site's frame: a chat that renders messages as buttons would otherwise print them whole. */
|
|
1318
|
-
|
|
1318
|
+
const MAX_FOREIGN_NAME = 40;
|
|
1319
1319
|
/** A control's name from another site's frame, cut to MAX_FOREIGN_NAME. */
|
|
1320
1320
|
export function capForeignName(name) {
|
|
1321
1321
|
return name.length > MAX_FOREIGN_NAME ? `${name.slice(0, MAX_FOREIGN_NAME)}…` : name;
|
package/dist/engine/crawl.js
CHANGED
|
@@ -48,7 +48,7 @@ export function crawlLine(o, counts, flags) {
|
|
|
48
48
|
(allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
|
|
49
49
|
}
|
|
50
50
|
/** The media types a browser renders as a page: anything else a route answers with is a file, a feed or data. */
|
|
51
|
-
|
|
51
|
+
const PAGE_MEDIA_TYPES = ["text/html", "application/xhtml+xml"];
|
|
52
52
|
/**
|
|
53
53
|
* A Content-Type header's media type, lowercased and without its parameters
|
|
54
54
|
* ("text/HTML; charset=utf-8" → "text/html"); undefined when there is none.
|
package/dist/engine/dedup.js
CHANGED
|
@@ -150,7 +150,7 @@ export const JUDGE_SYSTEM = "You decide whether two findings filed by explorator
|
|
|
150
150
|
"never below 0.5: near 0.5 when the text can barely tell, near 1 only when it plainly can. " +
|
|
151
151
|
"Example: verdict different with confidence 0.9 means a 10% chance they are one defect. Use unsure only when the findings give too little to go on.";
|
|
152
152
|
/** What the judge is shown: the fields a tester filed, nothing from the key. */
|
|
153
|
-
|
|
153
|
+
function judgeKickoff(route, a, b) {
|
|
154
154
|
const show = (f) => ({ title: f.title, category: f.category, evidence: f.evidence ?? "" });
|
|
155
155
|
return `Both findings were filed on the page ${route}.\n\nFinding A: ${JSON.stringify(show(a))}\nFinding B: ${JSON.stringify(show(b))}`;
|
|
156
156
|
}
|
|
@@ -223,7 +223,7 @@ export async function decideDuplicate(p, ask, ruleVerdict) {
|
|
|
223
223
|
}
|
|
224
224
|
// ── the judge the store asks ────────────────────────────────────────────────
|
|
225
225
|
/** Calls made about one filing at most: its most alike open findings on the page. */
|
|
226
|
-
|
|
226
|
+
const JUDGE_MAX_CALLS = 3;
|
|
227
227
|
/** The longest one judge call may take. */
|
|
228
228
|
export const JUDGE_CALL_MS = 15_000;
|
|
229
229
|
/**
|
|
@@ -231,9 +231,9 @@ export const JUDGE_CALL_MS = 15_000;
|
|
|
231
231
|
* scout_finding's 60-second watchdog, so a slow provider costs a filing its
|
|
232
232
|
* judge and never the filing.
|
|
233
233
|
*/
|
|
234
|
-
|
|
234
|
+
const JUDGE_FILING_MS = 40_000;
|
|
235
235
|
/** Failed calls in a row after which the judge is switched off for the rest of the run. */
|
|
236
|
-
|
|
236
|
+
const JUDGE_MAX_FAILURES = 3;
|
|
237
237
|
/** The most output one judge call may produce: the answer is one short tool call (24 to 26 tokens when measured). */
|
|
238
238
|
export const JUDGE_MAX_OUTPUT_TOKENS = 2_000;
|
|
239
239
|
/**
|
package/dist/engine/design.js
CHANGED
|
@@ -53,7 +53,7 @@ export function focusedControl(doc) {
|
|
|
53
53
|
* words, split at camelCase and punctuation, is filter(s) or facet(s). Whole
|
|
54
54
|
* words only, so "filterable-list" or "unfiltered" says nothing.
|
|
55
55
|
*/
|
|
56
|
-
|
|
56
|
+
const FILTER_WORDS_SRC = `(s) => (s || "").replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z]+/).some((w) => w === "filter" || w === "filters" || w === "facet" || w === "facets")`;
|
|
57
57
|
/** FILTER_WORDS_SRC run outside a page, from the same source, so a table test exercises the rule the collector ships. */
|
|
58
58
|
export function namesFilter(text) {
|
|
59
59
|
return new Function(`return (${FILTER_WORDS_SRC});`)()(text);
|
|
@@ -435,7 +435,7 @@ function gridValues(top) {
|
|
|
435
435
|
.join(", ");
|
|
436
436
|
}
|
|
437
437
|
/** The box a pointer user hits to operate the control: its label or drop zone where one operates it, else its own. */
|
|
438
|
-
|
|
438
|
+
function targetBox(r) {
|
|
439
439
|
return r.target ?? r.rect;
|
|
440
440
|
}
|
|
441
441
|
/** The WCAG 2.2 target-size minimum, in CSS pixels. */
|
|
@@ -536,7 +536,7 @@ export function distinguishingLayer(key, others) {
|
|
|
536
536
|
* stone), the shades text and surfaces are built from. Near-white and
|
|
537
537
|
* near-black are left out, as before: every page has them.
|
|
538
538
|
*/
|
|
539
|
-
|
|
539
|
+
function grayOf(rgb) {
|
|
540
540
|
const avg = (rgb[0] + rgb[1] + rgb[2]) / 3;
|
|
541
541
|
return hueOf(rgb) === null && avg > 20 && avg < 245;
|
|
542
542
|
}
|
|
@@ -574,7 +574,7 @@ const GRAY_STEP = 4;
|
|
|
574
574
|
* the audit and the snapshot agree on a page. Records with no name read are
|
|
575
575
|
* left out rather than guessed.
|
|
576
576
|
*/
|
|
577
|
-
|
|
577
|
+
function unnamedControls(records) {
|
|
578
578
|
const named = records.filter((r) => r.interactive && typeof r.name === "string");
|
|
579
579
|
const asSnapshot = (r) => ({
|
|
580
580
|
role: r.role,
|
|
@@ -606,7 +606,7 @@ function distanceToBox([px, py], b) {
|
|
|
606
606
|
* target's circle. `others` is every interactive record on the page, shell
|
|
607
607
|
* included, since a neighbour in the shell is as easy to hit by mistake.
|
|
608
608
|
*/
|
|
609
|
-
|
|
609
|
+
function spacedApart(r, others) {
|
|
610
610
|
const c = centreOf(targetBox(r));
|
|
611
611
|
for (const o of others) {
|
|
612
612
|
if (o === r || !o.interactive)
|
|
@@ -628,11 +628,11 @@ export function spacedApart(r, others) {
|
|
|
628
628
|
* A target that fails WCAG 2.5.8: under 24px, measured on what the user hits
|
|
629
629
|
* (targetBox), and not saved by the spacing exception.
|
|
630
630
|
*/
|
|
631
|
-
|
|
631
|
+
function tooSmall(r, all) {
|
|
632
632
|
return undersized(r) && !spacedApart(r, all);
|
|
633
633
|
}
|
|
634
634
|
/** Past this, a stretched image reads as distorted rather than as rounding. */
|
|
635
|
-
|
|
635
|
+
const ASPECT_TOLERANCE = 0.12;
|
|
636
636
|
/**
|
|
637
637
|
* How far an image is stretched out of its proportions, or null when it is not.
|
|
638
638
|
* Only `fill`, the default, stretches the picture to its box: `cover` and `contain`
|
package/dist/engine/expiry.js
CHANGED
|
@@ -154,7 +154,7 @@ export function readLifetime(state, opts = {}) {
|
|
|
154
154
|
return { credentials, dated, undated: credentials.length - dated.length, elsewhere, unreadableTokens };
|
|
155
155
|
}
|
|
156
156
|
/** A span as a person says it; days past two, since "73h00m" reads badly. */
|
|
157
|
-
|
|
157
|
+
function sayLifetime(ms) {
|
|
158
158
|
const days = Math.floor(ms / 86_400_000);
|
|
159
159
|
return days >= 2 ? `${days} days` : sayDuration(ms);
|
|
160
160
|
}
|
package/dist/engine/export.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* made inert before it goes into an issue.
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from "node:crypto";
|
|
15
|
+
import { readFlagValue } from "./args.js";
|
|
15
16
|
import { readFindingPicture } from "./capture.js";
|
|
16
17
|
import { CHECK_RULES, WORTH_A_LOOK_RULES } from "./check.js";
|
|
17
18
|
import { readFindings } from "./ci.js";
|
|
@@ -20,7 +21,7 @@ import { retryAfterMs } from "./provider.js";
|
|
|
20
21
|
import { answerTicket } from "./tickets.js";
|
|
21
22
|
const TRACKERS = ["github", "jira"];
|
|
22
23
|
export const TRACKER_LABEL = { github: "GitHub", jira: "Jira" };
|
|
23
|
-
|
|
24
|
+
const SEVERITIES = ["high", "medium", "low"];
|
|
24
25
|
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
25
26
|
/** Every option `scenescout export` accepts. guide-test holds the configuration reference equal to it. */
|
|
26
27
|
export const EXPORT_OPTION_NAMES = [
|
|
@@ -183,12 +184,11 @@ export function parseExportArgs(args, cwd, env) {
|
|
|
183
184
|
flags.set(name, "true");
|
|
184
185
|
continue;
|
|
185
186
|
}
|
|
186
|
-
const
|
|
187
|
-
if (
|
|
188
|
-
return
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
flags.set(name, value);
|
|
187
|
+
const read = readFlagValue(args, i);
|
|
188
|
+
if (!read.ok)
|
|
189
|
+
return read;
|
|
190
|
+
i = read.last;
|
|
191
|
+
flags.set(name, read.value);
|
|
192
192
|
}
|
|
193
193
|
const to = flags.get("to");
|
|
194
194
|
if (to === undefined)
|
package/dist/engine/flow.js
CHANGED
|
@@ -25,23 +25,23 @@ import { FIXTURE_KINDS } from "./fixtures.js";
|
|
|
25
25
|
import { MEMORY_DIRNAME, SELF_IGNORE_KEEP } from "./memory.js";
|
|
26
26
|
import { validateRoleName } from "./profiles.js";
|
|
27
27
|
/** Most steps one flow may hold. A longer flow is several flows, and each would fail more usefully on its own. */
|
|
28
|
-
|
|
28
|
+
const MAX_FLOW_STEPS = 50;
|
|
29
29
|
/** Most flows one check replays. */
|
|
30
|
-
|
|
30
|
+
const MAX_FLOWS = 50;
|
|
31
31
|
/**
|
|
32
32
|
* How long a flow waits after its last step for a write that step set off
|
|
33
33
|
* late (a save on a timer, a debounced autosave), before it counts as passed.
|
|
34
34
|
*/
|
|
35
35
|
export const FLOW_AFTER_LAST_STEP_MS = 750;
|
|
36
36
|
/** Where a project keeps its flows, relative to the project directory. */
|
|
37
|
-
|
|
37
|
+
const FLOWS_SUBDIR = path.join(MEMORY_DIRNAME, SELF_IGNORE_KEEP);
|
|
38
38
|
export const TARGET_HELP = 'testid=…, text=…, label=… or role=<role>[name="…"]';
|
|
39
39
|
/**
|
|
40
40
|
* The ARIA roles a `role=` target may name (WAI-ARIA 1.2, as Playwright's
|
|
41
41
|
* getByRole accepts them). A misspelt role matches nothing, so a flow using
|
|
42
42
|
* one would only ever fail at replay; it is refused when the file is read.
|
|
43
43
|
*/
|
|
44
|
-
|
|
44
|
+
const ARIA_ROLE_LIST = [
|
|
45
45
|
"alert",
|
|
46
46
|
"alertdialog",
|
|
47
47
|
"application",
|
|
@@ -126,7 +126,7 @@ export const ARIA_ROLE_LIST = [
|
|
|
126
126
|
"treeitem",
|
|
127
127
|
];
|
|
128
128
|
export const ARIA_ROLES = new Set(ARIA_ROLE_LIST);
|
|
129
|
-
|
|
129
|
+
const ARIA_ROLES_MATCH_PLAYWRIGHT = ARIA_ROLE_LIST;
|
|
130
130
|
/** Parse a target string, or null when it is not one. */
|
|
131
131
|
export function parseTarget(target) {
|
|
132
132
|
for (const by of ["testid", "text", "label"]) {
|
|
@@ -159,7 +159,7 @@ export function notActionable(action, facts) {
|
|
|
159
159
|
* when nothing matches: "the receipt was never shown" and "the dialog closed"
|
|
160
160
|
* are both the absence of a visible element.
|
|
161
161
|
*/
|
|
162
|
-
|
|
162
|
+
const ELEMENT_STATES = ["visible", "hidden", "enabled", "disabled", "checked", "unchecked"];
|
|
163
163
|
/**
|
|
164
164
|
* Whether an element in the state `facts` describes satisfies `state`, and if
|
|
165
165
|
* not, what is true instead, in the words the report uses. `facts` is null when
|
|
@@ -213,7 +213,7 @@ const target = z
|
|
|
213
213
|
*/
|
|
214
214
|
const expected = z.string().trim().min(1, "is the result the step should bring about, in words").max(500).optional();
|
|
215
215
|
/** Most times a repeat step may run its steps. A longer loop is a page that should be reached another way. */
|
|
216
|
-
|
|
216
|
+
const MAX_REPEATS = 100;
|
|
217
217
|
const SINGLE_STEP_SCHEMAS = [
|
|
218
218
|
z.object({ action: z.literal("navigate"), expected, target: z.string().regex(/^\//, "must be a path on the app, starting with /") }).strict(),
|
|
219
219
|
z.object({ action: z.literal("click"), expected, target }).strict(),
|
|
@@ -269,7 +269,7 @@ const repeatSchema = z
|
|
|
269
269
|
.strict();
|
|
270
270
|
const STEP_SCHEMAS = [...SINGLE_STEP_SCHEMAS, repeatSchema];
|
|
271
271
|
const stepSchema = z.discriminatedUnion("action", [...STEP_SCHEMAS]);
|
|
272
|
-
|
|
272
|
+
const FLOW_ACTIONS = STEP_SCHEMAS.map((s) => s.shape.action.value);
|
|
273
273
|
const flowSchema = z
|
|
274
274
|
.object({
|
|
275
275
|
name: z.string().min(1).max(100).optional(),
|
|
@@ -509,7 +509,7 @@ export function matchRequest(expect, seen) {
|
|
|
509
509
|
* (browsers.ts `beaconResourceType`). They are fire-and-forget by design, a
|
|
510
510
|
* page sends them on its own schedule, and no step waits for one.
|
|
511
511
|
*/
|
|
512
|
-
|
|
512
|
+
const BACKGROUND_REQUEST_TYPES = new Set(["ping", ...BROWSER_ENGINES.map(beaconResourceType)]);
|
|
513
513
|
/**
|
|
514
514
|
* Split what the write policy refused during a step into what the step is
|
|
515
515
|
* charged with and background requests, which are only listed. The rule is
|
|
@@ -534,7 +534,7 @@ export function splitRefusals(refusals, appOrigin) {
|
|
|
534
534
|
/** A value taken from the environment when the flow runs: `${env:NAME}` in a type or select step's value. */
|
|
535
535
|
const ENV_REF = /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
536
536
|
/** Shortest substituted value masked in the check's output. Shorter ones would mask ordinary words and numbers. */
|
|
537
|
-
|
|
537
|
+
const MIN_MASKED_ENV_VALUE = 4;
|
|
538
538
|
/** Every step that takes a value, the steps inside a repeat included. */
|
|
539
539
|
function valueSteps(steps) {
|
|
540
540
|
return steps.flatMap((s) => s.action === "repeat" ? valueSteps(s.steps) : "value" in s && s.action !== "press" ? [s] : []);
|
package/dist/engine/forms.js
CHANGED
|
@@ -135,7 +135,7 @@ export const APP_FILLED_TYPES = new Set(["date", "datetime-local", "month", "wee
|
|
|
135
135
|
* Input types in which Enter submits the form (the HTML standard's implicit
|
|
136
136
|
* submission). Not checkboxes, radios or buttons: Enter does nothing there.
|
|
137
137
|
*/
|
|
138
|
-
|
|
138
|
+
const IMPLICIT_SUBMIT_TYPES = new Set([...TEXT_ENTRY_TYPES, "search"]);
|
|
139
139
|
/** Whether a field takes typed text and a user could type into it right now. */
|
|
140
140
|
export function isTextEntry(f) {
|
|
141
141
|
if (!f.visible || f.disabled || f.readOnly)
|
package/dist/engine/from-run.js
CHANGED
|
@@ -27,7 +27,7 @@ export const FROM_RUN_ENV = "SCENESCOUT_FROM_RUN";
|
|
|
27
27
|
/** How to use it: `continue` (the default) or `replay`. */
|
|
28
28
|
export const FROM_RUN_MODE_ENV = "SCENESCOUT_FROM_RUN_MODE";
|
|
29
29
|
/** How many model turns a continued run budgets for each page it takes on: what sets its page cap (pageCap). */
|
|
30
|
-
|
|
30
|
+
const FROM_RUN_TURNS_PER_PAGE_ENV = "SCENESCOUT_FROM_RUN_TURNS_PER_PAGE";
|
|
31
31
|
/**
|
|
32
32
|
* Turns a continued run spends on one page by default: enough to exercise its
|
|
33
33
|
* controls, submit its forms and try its options, measured against what a run
|
|
@@ -36,13 +36,13 @@ export const FROM_RUN_TURNS_PER_PAGE_ENV = "SCENESCOUT_FROM_RUN_TURNS_PER_PAGE";
|
|
|
36
36
|
*/
|
|
37
37
|
export const DEFAULT_TURNS_PER_PAGE = 7;
|
|
38
38
|
/** The bounds of the setting. */
|
|
39
|
-
|
|
39
|
+
const TURNS_PER_PAGE_RANGE = { min: 1, max: 200 };
|
|
40
40
|
/**
|
|
41
41
|
* `continue`: start where the earlier run left off. `replay`: follow its route
|
|
42
42
|
* and step order again.
|
|
43
43
|
*/
|
|
44
44
|
export const FROM_RUN_MODES = ["continue", "replay"];
|
|
45
|
-
|
|
45
|
+
const DEFAULT_FROM_RUN_MODE = "continue";
|
|
46
46
|
/**
|
|
47
47
|
* The earlier run a run starts from: the path option, else the environment
|
|
48
48
|
* variable, else none; the mode option, else its variable, else `continue`.
|
|
@@ -86,11 +86,11 @@ export function pageCap(turns, turnsPerPage) {
|
|
|
86
86
|
return Math.max(1, Math.floor(turns / Math.max(1, turnsPerPage)));
|
|
87
87
|
}
|
|
88
88
|
/** The most steps a record keeps: a replay of more would not fit a model's first message anyway. */
|
|
89
|
-
|
|
89
|
+
const MAX_RECORD_STEPS = 400;
|
|
90
90
|
/** The most routes a record lists in any one list. */
|
|
91
|
-
|
|
91
|
+
const MAX_RECORD_ROUTES = 300;
|
|
92
92
|
/** The most controls, forms or options a record keeps per route. */
|
|
93
|
-
|
|
93
|
+
const MAX_RECORD_KEYS = 40;
|
|
94
94
|
/** The most records a project's memory keeps; the oldest go first. */
|
|
95
95
|
export const MAX_RECORDS = 20;
|
|
96
96
|
/** A route's identity for matching: two spellings of one page are one route. */
|
package/dist/engine/injection.js
CHANGED
|
@@ -22,7 +22,7 @@ export const MAX_PROBES = 60;
|
|
|
22
22
|
/** Text is compared on this many characters, on both sides, so a long fuzz value still matches its element. */
|
|
23
23
|
export const INJECTION_TEXT_MAX = 200;
|
|
24
24
|
/** Most matching elements reported per probe per page. */
|
|
25
|
-
|
|
25
|
+
const MAX_HITS = 20;
|
|
26
26
|
const OPEN_RE = /<([a-zA-Z][a-zA-Z0-9-]*)/;
|
|
27
27
|
// One attribute at a time, anchored where the last one ended: a slash between
|
|
28
28
|
// attributes is the same as a space to a browser (`<svg/onload=…>`). Each
|
package/dist/engine/lane.js
CHANGED
|
@@ -380,7 +380,7 @@ function pathSegments(path) {
|
|
|
380
380
|
* `order-{id}` is a literal, and so is `**`, which some routers read as "any
|
|
381
381
|
* number of segments": a template here stands for exactly one.
|
|
382
382
|
*/
|
|
383
|
-
|
|
383
|
+
function isTemplateSegment(seg) {
|
|
384
384
|
return seg === "*" || /^\{[^/{}]+\}$/.test(seg) || /^:[a-z_][a-z0-9_-]*$/i.test(seg);
|
|
385
385
|
}
|
|
386
386
|
/**
|
package/dist/engine/limits.js
CHANGED
|
@@ -18,7 +18,7 @@ export const DEFAULT_NAV_TIMEOUT_MS = 20000;
|
|
|
18
18
|
/** The crawl has always given each route less than a deliberate navigation. */
|
|
19
19
|
export const DEFAULT_CRAWL_NAV_TIMEOUT_MS = 15000;
|
|
20
20
|
/** Going back and a popup's load have always had the shortest wait. */
|
|
21
|
-
|
|
21
|
+
const DEFAULT_BACK_NAV_TIMEOUT_MS = 10000;
|
|
22
22
|
export const DEFAULT_TIME_LIMITS = {
|
|
23
23
|
actionMs: DEFAULT_ACTION_TIMEOUT_MS,
|
|
24
24
|
navMs: DEFAULT_NAV_TIMEOUT_MS,
|
|
@@ -42,7 +42,7 @@ function boundsText(kind) {
|
|
|
42
42
|
return `a whole number of milliseconds from ${min} to ${max}`;
|
|
43
43
|
}
|
|
44
44
|
/** Check one value against its bounds. `source` names where it came from, so the error says what to fix. */
|
|
45
|
-
|
|
45
|
+
function checkLimit(kind, value, source) {
|
|
46
46
|
const { min, max } = LIMIT_BOUNDS[kind];
|
|
47
47
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
48
48
|
throw new Error(`${source} must be ${boundsText(kind)} (got ${value}).`);
|
package/dist/engine/live.js
CHANGED
|
@@ -35,6 +35,36 @@ export const LIVE_TOKEN_FILE = "live-token";
|
|
|
35
35
|
export function liveTokenFileName(pid) {
|
|
36
36
|
return `${LIVE_TOKEN_FILE}.${pid}`;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Hand the live view's token to `scenescout watch`: this engine's own file,
|
|
40
|
+
* then the shared name. Each is born owner-only under a fresh temporary name
|
|
41
|
+
* and renamed into place. Writing to the name itself would keep the bits of a
|
|
42
|
+
* file already there (`mode` applies only on creation) and would follow a link
|
|
43
|
+
* left at it, sending the token into whatever file the link points at; a
|
|
44
|
+
* rename replaces the entry, link or file, and never writes through it.
|
|
45
|
+
*/
|
|
46
|
+
export async function writeLiveToken(dir, pid, token) {
|
|
47
|
+
for (const name of [liveTokenFileName(pid), LIVE_TOKEN_FILE]) {
|
|
48
|
+
const file = path.join(dir, name);
|
|
49
|
+
const temp = `${file}.${pid}.${crypto.randomBytes(6).toString("hex")}.tmp`;
|
|
50
|
+
// "wx": created here and now, so the mode given is the one it is born with, and never a link's target.
|
|
51
|
+
const handle = await fs.promises.open(temp, "wx", 0o600);
|
|
52
|
+
try {
|
|
53
|
+
try {
|
|
54
|
+
await handle.writeFile(token);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
await handle.close();
|
|
58
|
+
}
|
|
59
|
+
await fs.promises.rename(temp, file);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
// The temporary file holds the token too: nothing that failed may leave it behind.
|
|
63
|
+
await fs.promises.rm(temp, { force: true }).catch(() => { });
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
38
68
|
/** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
|
|
39
69
|
export const LIVE_ENV = "SCENESCOUT_LIVE";
|
|
40
70
|
/**
|