scenescout 3.19.1 → 3.20.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 +12 -0
- package/README.md +4 -1
- package/dist/check-run.js +175 -2
- package/dist/cli.js +31 -4
- package/dist/engine/browser.js +84 -5
- package/dist/engine/check-replay.js +287 -0
- package/dist/engine/check.js +34 -2
- package/dist/engine/claims.js +182 -11
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.20.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- eb79ce8: `scenescout check --record` (or `SCENESCOUT_RECORD=on`) keeps a frame after each route visit and each saved-flow step and writes `replay.html` beside the report: each role, each journey with a pass or fail badge, each step with its caption, result and frame, the first failing step highlighted, and the run's version, times, origin and commit in the header. `--video` records a WebM of each saved flow, each on a page of its own, and the replay page plays it beside the journey's steps. The GitHub Action gains `record` and `video` inputs and a `replay` output, and keeps the page, its frames and its videos in the uploaded artifact.
|
|
8
|
+
|
|
9
|
+
## 3.19.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 3b953a7: `refused-empty` now pairs an empty-state sentence with the refused read it is about (#413). A sentence that names what is missing ("No comments yet", "There are no orders") is reported at high when a refused read's path names the same thing, and not at all when a successful data read on the page fetched that thing instead: it is a genuinely empty section beside an unrelated refusal. A named sentence that matches neither is still reported, at medium, so `scenescout check` no longer fails a `--fail-on high` gate on it. Sentences that name nothing in particular ("No results", "Nothing to show") and empty rendered lists pair with any refused read at high, as before, and the finding names the refused read the sentence points at. Existing `refused-empty` fingerprints can change once on upgrade, because the evidence may now name a different read.
|
|
14
|
+
|
|
3
15
|
## 3.19.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -277,7 +277,9 @@ Use SceneScout to test http://localhost:3000, record the run
|
|
|
277
277
|
```
|
|
278
278
|
|
|
279
279
|
or, on the tool directly, `scout_attach {record: true}`. `SCENESCOUT_RECORD=on` in
|
|
280
|
-
the server's environment records every run.
|
|
280
|
+
the server's environment records every run. A CI gate records too:
|
|
281
|
+
`scenescout check --record` writes `replay.html`, every journey step by step with
|
|
282
|
+
its frames ([recording a check](docs/ci.md#recording-a-check)).
|
|
281
283
|
|
|
282
284
|
Then `scout_report` writes two files side by side in `.scenescout/`:
|
|
283
285
|
`report.md` as always, and `report.html` — the whole run as one self-contained
|
|
@@ -454,6 +456,7 @@ With the default settings its saved flows send no HTTP write (they replay under
|
|
|
454
456
|
- `--on-refused-step report|stop` (default `report`): `report` marks a flow whose step was refused "could not run", keeps every other verdict and exits 2; `stop` exits 2 at that step with no results.
|
|
455
457
|
- `--gate-retests never|high|all` (default `high`): which still-reproducing re-tested findings fail the gate.
|
|
456
458
|
- `--baseline off|compare|update` (default `off`), with `--baselines <dir>` and `--baseline-threshold <percent>` (default `0.1`, so small anti-aliasing noise between machines passes): visual baselines, below.
|
|
459
|
+
- `--record` (or `SCENESCOUT_RECORD=on`; the action's `record: on`) keeps a frame after each route visit and each saved-flow step and writes `replay.html` beside the report: each role, each journey with a pass or fail badge, each step with its caption, result and frame, the first failing step highlighted. `--video` (the action's `video: on`) adds a WebM of each journey, played on that page beside its steps. Both off by default. [docs/ci.md](docs/ci.md#recording-a-check) covers size, privacy and publishing it.
|
|
457
460
|
|
|
458
461
|
The defaults are what an unconfigured check does, for a first try or an AI agent running it unattended: its flows send no HTTP write and it never silently hides a result. Each setting is a choice for the project; the report and `check.json` print the values a check ran with.
|
|
459
462
|
|
package/dist/check-run.js
CHANGED
|
@@ -19,6 +19,7 @@ import { decodePng, encodePng } from "./engine/png.js";
|
|
|
19
19
|
import { firstLineOf } from "./engine/limits.js";
|
|
20
20
|
import { isNonPageResource } from "./engine/crawl.js";
|
|
21
21
|
import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
|
|
22
|
+
import { capFrames, isReplayFrameFile, journeyOf, journeyVideoPath, isJourneyVideoFile, redactReplay, REPLAY_VIDEOS_DIRNAME, replayVideos, REPLAY_FILE, REPLAY_FRAMES_DIRNAME, replayFramePath, replayFrames, replaySessionKey, visitOf, } from "./engine/check-replay.js";
|
|
22
23
|
/** The baselines folder a check uses: the one --baselines names, else the project's own. */
|
|
23
24
|
export function baselinesDirOf(options) {
|
|
24
25
|
return options.baselinesDir ?? defaultBaselinesDir(options.projectDir);
|
|
@@ -99,6 +100,21 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
99
100
|
const engine = new BrowserEngine();
|
|
100
101
|
/** One browser per role a flow runs as, each attached once, on first use, and closed with the check's own. */
|
|
101
102
|
const roleEngines = new Map();
|
|
103
|
+
// --record: every browser keeps a frame after each step, under its own session name, in its own memory folder.
|
|
104
|
+
const record = options.record === true;
|
|
105
|
+
const startedAt = new Date().toISOString();
|
|
106
|
+
const store = new MemoryStore(scratch);
|
|
107
|
+
if (record)
|
|
108
|
+
engine.sessionKey = replaySessionKey();
|
|
109
|
+
// --video: each browser records every page into its own scratch folder, and each flow's own page is saved beside the report.
|
|
110
|
+
const video = options.video === true;
|
|
111
|
+
const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
|
|
112
|
+
// The default folder is inside .scenescout/, which ignores itself; its .gitignore goes first, so no frame or video is ever unignored.
|
|
113
|
+
if ((record || video) && !options.outDir) {
|
|
114
|
+
const scenescoutDir = path.join(options.projectDir, MEMORY_DIRNAME);
|
|
115
|
+
fs.mkdirSync(scenescoutDir, { recursive: true });
|
|
116
|
+
writeSelfIgnore(scenescoutDir);
|
|
117
|
+
}
|
|
102
118
|
const start = new URL(options.url);
|
|
103
119
|
// From the start of the run, browser launch included: the budget is wall-clock time a person waits.
|
|
104
120
|
const deadline = options.timeBudgetMs !== undefined ? Date.now() + options.timeBudgetMs : undefined;
|
|
@@ -109,9 +125,11 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
109
125
|
const attached = await engine.attach({
|
|
110
126
|
url: start.origin,
|
|
111
127
|
projectDir: options.projectDir,
|
|
112
|
-
memoryStore:
|
|
128
|
+
memoryStore: store,
|
|
113
129
|
mode: options.mode,
|
|
114
130
|
storageStatePath: options.storageStatePath,
|
|
131
|
+
...(record ? { record: true } : {}),
|
|
132
|
+
...(video ? { videoDir: path.join(scratch, "video") } : {}),
|
|
115
133
|
...(options.browser ? { browser: options.browser } : {}),
|
|
116
134
|
actionTimeoutMs: options.actionTimeoutMs,
|
|
117
135
|
navTimeoutMs: options.navTimeoutMs,
|
|
@@ -178,6 +196,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
178
196
|
return known;
|
|
179
197
|
const roleEngine = new BrowserEngine();
|
|
180
198
|
roleEngines.set(role, roleEngine);
|
|
199
|
+
if (record)
|
|
200
|
+
roleEngine.sessionKey = replaySessionKey(role);
|
|
181
201
|
const roleDir = path.join(scratch, `role-${role}`);
|
|
182
202
|
fs.mkdirSync(roleDir, { recursive: true });
|
|
183
203
|
const attachedAs = await roleEngine.attach({
|
|
@@ -186,6 +206,8 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
186
206
|
memoryStore: new MemoryStore(roleDir),
|
|
187
207
|
mode: options.mode,
|
|
188
208
|
role,
|
|
209
|
+
...(record ? { record: true } : {}),
|
|
210
|
+
...(video ? { videoDir: path.join(roleDir, "video") } : {}),
|
|
189
211
|
...(options.browser ? { browser: options.browser } : {}),
|
|
190
212
|
actionTimeoutMs: options.actionTimeoutMs,
|
|
191
213
|
navTimeoutMs: options.navTimeoutMs,
|
|
@@ -197,10 +219,29 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
197
219
|
throw new Error(`the saved sign-in for role "${role}" no longer signs in: ${lost.replace(/ Continuing now tests a logged-out app\.$/, "")}`);
|
|
198
220
|
return roleEngine;
|
|
199
221
|
};
|
|
222
|
+
/** On a recorded check, the frames each flow's steps left, beside the flow. */
|
|
223
|
+
const flowFrames = [];
|
|
224
|
+
/** On a check with --video, each flow's video beside the report (relative to it), or null where none was saved. */
|
|
225
|
+
const flowVideos = [];
|
|
200
226
|
for (const flow of runFlows) {
|
|
201
227
|
const runner = await engineFor(flow.role);
|
|
202
228
|
// --flow-writes never: observe's rule, whatever --mode lets the crawl do.
|
|
203
|
-
const
|
|
229
|
+
const walk = () => runner.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
|
|
230
|
+
let walked;
|
|
231
|
+
if (video) {
|
|
232
|
+
// A page of its own, so the video is this journey's alone; the context, and so the sign-in, is the role's.
|
|
233
|
+
const rel = journeyVideoPath(flowRuns.length + 1, flow.file);
|
|
234
|
+
const own = await runner.onOwnPage(walk, path.join(outDir, ...rel.split("/")));
|
|
235
|
+
walked = own.value;
|
|
236
|
+
flowVideos.push(own.video ? rel : null);
|
|
237
|
+
if (own.videoError)
|
|
238
|
+
log(` flow ${flow.name}: its video could not be saved (${own.videoError})`);
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
walked = await walk();
|
|
242
|
+
}
|
|
243
|
+
const { frames, ...replay } = walked;
|
|
244
|
+
flowFrames.push(frames ?? []);
|
|
204
245
|
flowRuns.push({ name: flow.name, file: flow.file, steps: flow.steps.length, ...(flow.role === undefined ? {} : { role: flow.role }), ...replay });
|
|
205
246
|
log(` flow ${flow.name}${flow.role === undefined ? "" : ` (as ${flow.role})`}: ${replay.outcome.status}${replay.outcome.status === "passed" ? "" : ` at step ${replay.outcome.step}`}`);
|
|
206
247
|
// --on-refused-step stop: nothing after a refused step runs, and the check ends without a verdict.
|
|
@@ -222,6 +263,22 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
222
263
|
}))),
|
|
223
264
|
}
|
|
224
265
|
: null;
|
|
266
|
+
const replay = record || video
|
|
267
|
+
? recordReplay({
|
|
268
|
+
options,
|
|
269
|
+
startedAt,
|
|
270
|
+
ownRole: engine.role,
|
|
271
|
+
log: store.actionLog,
|
|
272
|
+
visited: [...routes, ...retestPages],
|
|
273
|
+
flows: runFlows
|
|
274
|
+
.slice(0, flowRuns.length)
|
|
275
|
+
.map((flow, i) => ({ flow, outcome: flowRuns[i].outcome, frames: flowFrames[i], video: flowVideos[i] ?? null })),
|
|
276
|
+
scratch,
|
|
277
|
+
say: log,
|
|
278
|
+
})
|
|
279
|
+
: null;
|
|
280
|
+
if (replay)
|
|
281
|
+
log(` kept ${replayFrames(replay).length} frame(s) and ${replayVideos(replay).length} video(s) for ${REPLAY_FILE}`);
|
|
225
282
|
const { pages: measured, resources } = splitResources(redactRoutes(routes.map(withoutOwnResponse)));
|
|
226
283
|
const flows = redactFlowRuns(flowRuns);
|
|
227
284
|
const pictured = baselines ? redactBaselineRun(baselines) : null;
|
|
@@ -248,6 +305,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
248
305
|
retest,
|
|
249
306
|
settings: settingsOf(options),
|
|
250
307
|
baselines: pictured,
|
|
308
|
+
...(replay ? { replay } : {}),
|
|
251
309
|
};
|
|
252
310
|
// A value taken from the environment (a code, a password) is never written, wherever the page echoed it.
|
|
253
311
|
return envValues.size > 0 ? JSON.parse(maskEnvValues(JSON.stringify(result), envValues)) : result;
|
|
@@ -403,3 +461,118 @@ async function takeBaselines(engine, options, mode, targets, log) {
|
|
|
403
461
|
}
|
|
404
462
|
return { mode, engine: engineName, threshold: options.baselineThreshold, dir: shownDir(dir, options.projectDir), results };
|
|
405
463
|
}
|
|
464
|
+
/**
|
|
465
|
+
* Remove what an earlier recorded check left beside the report: replay.html,
|
|
466
|
+
* the frames under replay-frames/ and the journey videos under replay-videos/
|
|
467
|
+
* that a check names as it names them (isReplayFrameFile, isJourneyVideoFile),
|
|
468
|
+
* then any folder that leaves empty. Run before every
|
|
469
|
+
* check, recorded or not, so a page from an earlier run is never read, or
|
|
470
|
+
* uploaded, as this one's; whatever else the folder holds stays.
|
|
471
|
+
*/
|
|
472
|
+
export function clearReplayOutput(outDir) {
|
|
473
|
+
fs.rmSync(path.join(outDir, REPLAY_FILE), { force: true });
|
|
474
|
+
const videos = path.join(outDir, REPLAY_VIDEOS_DIRNAME);
|
|
475
|
+
if (fs.existsSync(videos)) {
|
|
476
|
+
for (const file of fs.readdirSync(videos, { withFileTypes: true }))
|
|
477
|
+
if (file.isFile() && isJourneyVideoFile(file.name))
|
|
478
|
+
fs.rmSync(path.join(videos, file.name));
|
|
479
|
+
if (fs.readdirSync(videos).length === 0)
|
|
480
|
+
fs.rmdirSync(videos);
|
|
481
|
+
}
|
|
482
|
+
const root = path.join(outDir, REPLAY_FRAMES_DIRNAME);
|
|
483
|
+
let folders;
|
|
484
|
+
try {
|
|
485
|
+
folders = fs.readdirSync(root, { withFileTypes: true });
|
|
486
|
+
}
|
|
487
|
+
catch (err) {
|
|
488
|
+
if (err.code === "ENOENT")
|
|
489
|
+
return;
|
|
490
|
+
throw err;
|
|
491
|
+
}
|
|
492
|
+
for (const folder of folders.filter((f) => f.isDirectory())) {
|
|
493
|
+
const dir = path.join(root, folder.name);
|
|
494
|
+
for (const file of fs.readdirSync(dir, { withFileTypes: true }))
|
|
495
|
+
if (file.isFile() && isReplayFrameFile(file.name))
|
|
496
|
+
fs.rmSync(path.join(dir, file.name));
|
|
497
|
+
if (fs.readdirSync(dir).length === 0)
|
|
498
|
+
fs.rmdirSync(dir);
|
|
499
|
+
}
|
|
500
|
+
if (fs.readdirSync(root).length === 0)
|
|
501
|
+
fs.rmdirSync(root);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* The replay page's model for a recorded check, with its frames copied from
|
|
505
|
+
* the browsers' scratch folders to replay-frames/ beside the report: routes
|
|
506
|
+
* visited in the check's own session, then each flow under the role it ran
|
|
507
|
+
* as. Capped and redacted here, so what is copied is exactly what the page shows.
|
|
508
|
+
*/
|
|
509
|
+
function recordReplay(o) {
|
|
510
|
+
const outDir = o.options.outDir ?? defaultCheckDir(o.options.projectDir);
|
|
511
|
+
/** Where each frame the page names is copied from. */
|
|
512
|
+
const sources = new Map();
|
|
513
|
+
const beside = (recorded, memoryDir) => {
|
|
514
|
+
const to = recorded ? replayFramePath(recorded) : null;
|
|
515
|
+
if (to && recorded)
|
|
516
|
+
sources.set(to, path.join(memoryDir, ...recorded.split("/")));
|
|
517
|
+
return to;
|
|
518
|
+
};
|
|
519
|
+
// The crawl's frames, by the path each visit asked for, in the order they were taken.
|
|
520
|
+
const own = replaySessionKey();
|
|
521
|
+
const crawled = new Map();
|
|
522
|
+
for (const e of o.log) {
|
|
523
|
+
if (e.action !== "crawl" || !e.frame || e.target === undefined || (e.session ?? own) !== own)
|
|
524
|
+
continue;
|
|
525
|
+
crawled.set(e.target, [...(crawled.get(e.target) ?? []), e.frame]);
|
|
526
|
+
}
|
|
527
|
+
const ownSession = {
|
|
528
|
+
role: o.ownRole,
|
|
529
|
+
own: true,
|
|
530
|
+
// A MemoryStore keeps its files, frames included, in the .scenescout folder of the directory it is given.
|
|
531
|
+
visits: o.visited.map((route) => visitOf(route, beside(crawled.get(route.path)?.shift(), path.join(o.scratch, MEMORY_DIRNAME)) ?? undefined)),
|
|
532
|
+
journeys: [],
|
|
533
|
+
};
|
|
534
|
+
const roles = [ownSession];
|
|
535
|
+
for (const { flow, outcome, frames, video } of o.flows) {
|
|
536
|
+
const memoryDir = path.join(flow.role === undefined ? o.scratch : path.join(o.scratch, `role-${flow.role}`), MEMORY_DIRNAME);
|
|
537
|
+
const journey = journeyOf(flow, outcome, frames.map((f) => beside(f, memoryDir)));
|
|
538
|
+
if (video)
|
|
539
|
+
journey.video = video;
|
|
540
|
+
let role = flow.role === undefined ? ownSession : roles.find((r) => !r.own && r.role === flow.role);
|
|
541
|
+
if (!role) {
|
|
542
|
+
role = { role: flow.role, own: false, visits: [], journeys: [] };
|
|
543
|
+
roles.push(role);
|
|
544
|
+
}
|
|
545
|
+
role.journeys.push(journey);
|
|
546
|
+
}
|
|
547
|
+
const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0 }));
|
|
548
|
+
// A frame that cannot be copied is left off the page rather than shown broken.
|
|
549
|
+
const copied = new Set();
|
|
550
|
+
let failed = false;
|
|
551
|
+
for (const rel of replayFrames(replay)) {
|
|
552
|
+
const from = sources.get(rel);
|
|
553
|
+
if (!from)
|
|
554
|
+
continue;
|
|
555
|
+
try {
|
|
556
|
+
const to = path.join(outDir, ...rel.split("/"));
|
|
557
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
558
|
+
fs.copyFileSync(from, to);
|
|
559
|
+
copied.add(rel);
|
|
560
|
+
}
|
|
561
|
+
catch (err) {
|
|
562
|
+
// Said once: a folder that refuses one frame usually refuses them all.
|
|
563
|
+
if (!failed)
|
|
564
|
+
o.say(` could not copy a recorded frame to ${outDir}: ${firstLineOf(err)}; frames that could not be copied are left off ${REPLAY_FILE}`);
|
|
565
|
+
failed = true;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const keep = (item) => {
|
|
569
|
+
if (!item.frame || copied.has(item.frame))
|
|
570
|
+
return item;
|
|
571
|
+
const { frame: _gone, ...rest } = item;
|
|
572
|
+
return rest;
|
|
573
|
+
};
|
|
574
|
+
return {
|
|
575
|
+
...replay,
|
|
576
|
+
roles: replay.roles.map((r) => ({ ...r, visits: r.visits.map(keep), journeys: r.journeys.map((j) => ({ ...j, steps: j.steps.map(keep) })) })),
|
|
577
|
+
};
|
|
578
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,9 @@ import { APPROX_DISK_MB, defaultAttachNote, defaultEngine, launchTarget, parseBr
|
|
|
21
21
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
22
22
|
import { CLAUDE_CODE_NOT_NEEDED, CLI_NAME, desktopExtensionRoots, diagnose, doctorAllGood, findDesktopExtension, installClosing, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
23
23
|
import { downloadBrowsers, presentBrowsers } from "./installer.js";
|
|
24
|
-
import { baselinesDirOf, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
24
|
+
import { baselinesDirOf, clearReplayOutput, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
25
|
+
import { buildCheckReplayHtml, commitOf, REPLAY_FILE, replayFrames, replayVideos } from "./engine/check-replay.js";
|
|
26
|
+
import { recordChoice } from "./engine/capture.js";
|
|
25
27
|
import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
|
|
26
28
|
import { runExport } from "./export-run.js";
|
|
27
29
|
import { runLogin, runScriptedLogin, savedLine } from "./login-run.js";
|
|
@@ -29,7 +31,7 @@ import { credentialRedactor, LOGIN_ENV, readScriptedLogin } from "./engine/scrip
|
|
|
29
31
|
import { parseLoginArgs } from "./engine/profiles.js";
|
|
30
32
|
import { detectProvider, EXIT_CI, judgeEffort, KEY_ENV, parseCiArgs, redactKeys, secretValues } from "./engine/ci.js";
|
|
31
33
|
import { VISUAL_DIRNAME } from "./engine/baseline.js";
|
|
32
|
-
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
|
|
34
|
+
import { EXIT, exitCodeOf, formatCheck, parseCheckArgs, refusedFlowReason, summarise, toSarif, toSummaryJson, unmeasuredReason, } from "./engine/check.js";
|
|
33
35
|
import { downloadLine, EXIT_FIRST_RUN, FIRST_RUN_DIRNAME, firstRunCheckOptions, firstRunDownloads, firstRunSummary, formatFirstRun, modeSentence, parseFirstRunArgs, reportFolderProblem, unreachableReason, writeFirstRunReport, } from "./first-run.js";
|
|
34
36
|
import { credentialSecrets, EXIT_EXPORT, parseExportArgs } from "./engine/export.js";
|
|
35
37
|
import { LEGACY_MEMORY_DIRNAME, MEMORY_DIRNAME, writeSelfIgnore } from "./engine/memory.js";
|
|
@@ -105,7 +107,13 @@ Usage:
|
|
|
105
107
|
passes; 0 counts every changed pixel);
|
|
106
108
|
--sarif-file-anchor path: the repository file a SARIF result points at when
|
|
107
109
|
no saved flow raised it (default: the running workflow's file on GitHub
|
|
108
|
-
Actions, else package.json, else README.md)
|
|
110
|
+
Actions, else package.json, else README.md);
|
|
111
|
+
--record [on|off]: keep a frame after each route visit and each flow step
|
|
112
|
+
and write replay.html beside the report, role → journey → step (default:
|
|
113
|
+
SCENESCOUT_RECORD, else off; the frames go in replay-frames/);
|
|
114
|
+
--video [on|off]: record a WebM of each saved flow, each on a page of its
|
|
115
|
+
own, into replay-videos/, linked from replay.html beside its steps
|
|
116
|
+
(default off))
|
|
109
117
|
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
110
118
|
scenescout ci <url> An exploratory run with no person present: a model reached through its API
|
|
111
119
|
drives the tools by the SceneScout method and the run ends in the report.
|
|
@@ -570,10 +578,14 @@ async function check(args) {
|
|
|
570
578
|
console.error(`scenescout check: ${parsed.error}`);
|
|
571
579
|
process.exit(EXIT.error);
|
|
572
580
|
}
|
|
573
|
-
|
|
581
|
+
let options = parsed.options;
|
|
574
582
|
const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
|
|
575
583
|
let inputs;
|
|
576
584
|
try {
|
|
585
|
+
// --record, else SCENESCOUT_RECORD, else off.
|
|
586
|
+
options = { ...options, record: recordChoice(options.record, process.env) };
|
|
587
|
+
// A replay page an earlier run left must never be read, or uploaded, as this run's.
|
|
588
|
+
clearReplayOutput(outDir);
|
|
577
589
|
inputs = readCheckInputs(options);
|
|
578
590
|
}
|
|
579
591
|
catch (err) {
|
|
@@ -618,6 +630,19 @@ async function check(args) {
|
|
|
618
630
|
console.error(`scenescout check: ${sarifFiles.warning}`);
|
|
619
631
|
fs.writeFileSync(path.join(outDir, "check.sarif"), JSON.stringify(toSarif(result, version, sarifFiles), null, 2) + "\n");
|
|
620
632
|
fs.writeFileSync(path.join(outDir, "check.json"), JSON.stringify(toSummaryJson(result, version), null, 2) + "\n");
|
|
633
|
+
if (result.replay) {
|
|
634
|
+
const { passed, couldNotRun } = summarise(result);
|
|
635
|
+
const html = buildCheckReplayHtml(result.replay, {
|
|
636
|
+
version,
|
|
637
|
+
origin: new URL(result.url).origin,
|
|
638
|
+
startedAt: result.replay.startedAt,
|
|
639
|
+
endedAt: result.generatedAt,
|
|
640
|
+
commit: commitOf(process.env),
|
|
641
|
+
passed,
|
|
642
|
+
couldNotRun,
|
|
643
|
+
});
|
|
644
|
+
fs.writeFileSync(path.join(outDir, REPLAY_FILE), html);
|
|
645
|
+
}
|
|
621
646
|
// On GitHub Actions the verdict also goes on the run's summary page.
|
|
622
647
|
if (process.env.GITHUB_STEP_SUMMARY)
|
|
623
648
|
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, markdown);
|
|
@@ -629,6 +654,8 @@ async function check(args) {
|
|
|
629
654
|
}
|
|
630
655
|
console.log("\n" + markdown);
|
|
631
656
|
console.log(`Wrote report.md, check.sarif and check.json to ${outDir}`);
|
|
657
|
+
if (result.replay)
|
|
658
|
+
console.log(`Wrote ${REPLAY_FILE} to ${outDir}, with ${replayFrames(result.replay).length} frame(s) and ${replayVideos(result.replay).length} journey video(s) beside it: open it in a browser to see each step`);
|
|
632
659
|
const pictured = result.baselines?.results.filter((r) => r.files).length ?? 0;
|
|
633
660
|
if (pictured > 0)
|
|
634
661
|
console.log(`Wrote the pictures of ${pictured} changed baseline(s) under ${path.join(outDir, VISUAL_DIRNAME)}`);
|
package/dist/engine/browser.js
CHANGED
|
@@ -935,9 +935,13 @@ export class BrowserEngine {
|
|
|
935
935
|
}
|
|
936
936
|
}
|
|
937
937
|
for (const found of findContradictions(requests, state, before, acted && actedNow ? { before: acted.before, after: actedNow } : null)) {
|
|
938
|
-
|
|
938
|
+
// Once per refused request a session, except that a high reading of it
|
|
939
|
+
// replaces an earlier medium one (#413).
|
|
940
|
+
const severity = found.severity ?? "high";
|
|
941
|
+
const earlier = this.contradictionsReported.get(found.evidence);
|
|
942
|
+
if (earlier === "high" || earlier === severity)
|
|
939
943
|
continue;
|
|
940
|
-
this.contradictionsReported.
|
|
944
|
+
this.contradictionsReported.set(found.evidence, severity);
|
|
941
945
|
this.oracles.noteContradiction(found, url);
|
|
942
946
|
}
|
|
943
947
|
}
|
|
@@ -974,7 +978,7 @@ export class BrowserEngine {
|
|
|
974
978
|
}
|
|
975
979
|
}
|
|
976
980
|
/** Contradiction signatures already reported this session — the same refused endpoint on every page must not flood the run. */
|
|
977
|
-
contradictionsReported = new
|
|
981
|
+
contradictionsReported = new Map();
|
|
978
982
|
async scanForInjections() {
|
|
979
983
|
const page = this.page;
|
|
980
984
|
// One copy for the whole scan: the list is shared with every lane, and
|
|
@@ -1109,7 +1113,7 @@ export class BrowserEngine {
|
|
|
1109
1113
|
this.blockNotices.reset();
|
|
1110
1114
|
this.dialogsSeen = [];
|
|
1111
1115
|
this.watchedResponses = [];
|
|
1112
|
-
this.contradictionsReported = new
|
|
1116
|
+
this.contradictionsReported = new Map();
|
|
1113
1117
|
this.tokenPostsReported = new Set();
|
|
1114
1118
|
this.pendingCreations = new Set();
|
|
1115
1119
|
this.baseUrl = opts.url.replace(/\/$/, "");
|
|
@@ -1180,6 +1184,7 @@ export class BrowserEngine {
|
|
|
1180
1184
|
viewport: opts.viewport ?? { width: 1280, height: 900 },
|
|
1181
1185
|
...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
|
|
1182
1186
|
serviceWorkers: serviceWorkerPolicy(this.engineName),
|
|
1187
|
+
...(opts.videoDir ? { recordVideo: { dir: opts.videoDir } } : {}),
|
|
1183
1188
|
});
|
|
1184
1189
|
const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
|
|
1185
1190
|
if (restoreSession)
|
|
@@ -1505,6 +1510,9 @@ export class BrowserEngine {
|
|
|
1505
1510
|
// oracles attached); close foreign-origin popups so exploration cannot
|
|
1506
1511
|
// silently escape the app under test.
|
|
1507
1512
|
this.context.on("page", (newPage) => {
|
|
1513
|
+
// A page the engine opened for itself (onOwnPage) is not a popup to adopt or close.
|
|
1514
|
+
if (this.openingOwnPage)
|
|
1515
|
+
return;
|
|
1508
1516
|
newPage
|
|
1509
1517
|
.waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
|
|
1510
1518
|
.then(() => {
|
|
@@ -1667,6 +1675,66 @@ export class BrowserEngine {
|
|
|
1667
1675
|
nativeDialogAt = 0;
|
|
1668
1676
|
/** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
|
|
1669
1677
|
nativeQuestionAt = 0;
|
|
1678
|
+
/** True while onOwnPage opens its page, so the popup handler leaves it alone. */
|
|
1679
|
+
openingOwnPage = false;
|
|
1680
|
+
/** Open a page in this session's context and drive it from now on, wired as attach wires its first page. */
|
|
1681
|
+
async driveNewPage() {
|
|
1682
|
+
const context = this.context;
|
|
1683
|
+
if (!context)
|
|
1684
|
+
throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
|
|
1685
|
+
this.openingOwnPage = true;
|
|
1686
|
+
let page;
|
|
1687
|
+
try {
|
|
1688
|
+
page = await context.newPage();
|
|
1689
|
+
}
|
|
1690
|
+
finally {
|
|
1691
|
+
this.openingOwnPage = false;
|
|
1692
|
+
}
|
|
1693
|
+
this.oracles.attach(page);
|
|
1694
|
+
this.wireDialogHandler(page);
|
|
1695
|
+
this.wireEmbedMoves(page);
|
|
1696
|
+
this.page = page;
|
|
1697
|
+
this.refs.clear();
|
|
1698
|
+
this.snapshotUrl = "";
|
|
1699
|
+
this.forgetSnapshots();
|
|
1700
|
+
return page;
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* Run `work` on a page of its own in this session's context, then close it.
|
|
1704
|
+
* The context is the session's, so its cookies and storage carry over; on a
|
|
1705
|
+
* session attached with `videoDir`, the page's video is this run's alone,
|
|
1706
|
+
* and is saved to `videoTo`. Every page the work left open is closed with
|
|
1707
|
+
* it, and a fresh page is driven afterwards. The video is null when the
|
|
1708
|
+
* session records none or it could not be saved (`videoError` says why).
|
|
1709
|
+
*/
|
|
1710
|
+
async onOwnPage(work, videoTo) {
|
|
1711
|
+
const own = await this.driveNewPage();
|
|
1712
|
+
let value;
|
|
1713
|
+
try {
|
|
1714
|
+
value = await work();
|
|
1715
|
+
}
|
|
1716
|
+
finally {
|
|
1717
|
+
const context = this.context;
|
|
1718
|
+
// The work may have adopted a popup, or been left on its own page: everything open now is the work's.
|
|
1719
|
+
if (context)
|
|
1720
|
+
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => p.close().catch(() => { }))), 10_000);
|
|
1721
|
+
this.page = null;
|
|
1722
|
+
if (context)
|
|
1723
|
+
await this.driveNewPage();
|
|
1724
|
+
}
|
|
1725
|
+
const recording = own.video();
|
|
1726
|
+
if (!recording || !videoTo)
|
|
1727
|
+
return { value, video: null };
|
|
1728
|
+
try {
|
|
1729
|
+
await fs.promises.mkdir(path.dirname(videoTo), { recursive: true });
|
|
1730
|
+
// saveAs waits until the page is closed and the video written.
|
|
1731
|
+
await recording.saveAs(videoTo);
|
|
1732
|
+
return { value, video: videoTo };
|
|
1733
|
+
}
|
|
1734
|
+
catch (err) {
|
|
1735
|
+
return { value, video: null, videoError: err instanceof Error ? err.message.split("\n")[0] : String(err) };
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1670
1738
|
requirePage() {
|
|
1671
1739
|
if (!this.page || !this.memory) {
|
|
1672
1740
|
throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
|
|
@@ -5081,7 +5149,15 @@ export class BrowserEngine {
|
|
|
5081
5149
|
refusedBackground.push(sig);
|
|
5082
5150
|
return charged;
|
|
5083
5151
|
};
|
|
5084
|
-
|
|
5152
|
+
/** On a recorded session, the frame after each step that ran (null where none was kept), for the check's replay page. */
|
|
5153
|
+
const frames = [];
|
|
5154
|
+
const done = (outcome) => ({
|
|
5155
|
+
outcome,
|
|
5156
|
+
violations,
|
|
5157
|
+
refusedBackground,
|
|
5158
|
+
websockets: [...websockets],
|
|
5159
|
+
...(this.recording ? { frames } : {}),
|
|
5160
|
+
});
|
|
5085
5161
|
// Nothing from the crawl before this flow is charged to it.
|
|
5086
5162
|
this.oracles.drain(false);
|
|
5087
5163
|
this.blockedRequests = [];
|
|
@@ -5265,6 +5341,9 @@ export class BrowserEngine {
|
|
|
5265
5341
|
const n = i + 1;
|
|
5266
5342
|
const did = describeStep(step);
|
|
5267
5343
|
const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
|
|
5344
|
+
// Taken whatever the step's result: the frame of a step that broke is the evidence of how it broke.
|
|
5345
|
+
if (this.recording)
|
|
5346
|
+
frames.push((await this.recordFrame(`flow-${step.action}`)) ?? null);
|
|
5268
5347
|
if (refusal)
|
|
5269
5348
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
5270
5349
|
if (failure)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The replay page of a recorded `scenescout check` (`--record`): what each
|
|
3
|
+
* role did, journey by journey and step by step, with the frame the page
|
|
4
|
+
* showed after each step, so a green run leaves evidence of what passed and a
|
|
5
|
+
* red one shows where it broke.
|
|
6
|
+
*
|
|
7
|
+
* A journey is a saved flow (flow.ts); a role is the session it ran in, the
|
|
8
|
+
* check's own or the one its `role` names. The check's own session also lists
|
|
9
|
+
* the routes it visited, one frame each.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is pure: the mapping of steps to captions and results, the
|
|
12
|
+
* frame cap, the redaction and the page itself are table-tested. check-run.ts
|
|
13
|
+
* gathers the frames from the browser and copies them beside the page.
|
|
14
|
+
*/
|
|
15
|
+
import { redactRoute } from "./memory.js";
|
|
16
|
+
import { describeStep } from "./flow.js";
|
|
17
|
+
import { escapeHtml, plainSegment, RECORD_MAX_FRAMES } from "./replay.js";
|
|
18
|
+
/** The file the page is written to, beside report.md. */
|
|
19
|
+
export const REPLAY_FILE = "replay.html";
|
|
20
|
+
/** The folder beside it that holds the frames the page shows. */
|
|
21
|
+
export const REPLAY_FRAMES_DIRNAME = "replay-frames";
|
|
22
|
+
/** The folder beside it that holds one video per journey (--video). */
|
|
23
|
+
export const REPLAY_VIDEOS_DIRNAME = "replay-videos";
|
|
24
|
+
/**
|
|
25
|
+
* Each step of a replayed flow with its caption, its result and its frame.
|
|
26
|
+
* The replay stops at the first step that breaks, so every step before it
|
|
27
|
+
* passed, it is the failing one, and those after it never ran (and have no
|
|
28
|
+
* frame). `frames` holds one entry per executed step, null where none was kept.
|
|
29
|
+
*/
|
|
30
|
+
export function journeySteps(steps, outcome, frames = []) {
|
|
31
|
+
const broke = outcome.status === "passed" ? null : outcome.step;
|
|
32
|
+
return steps.map((step, i) => {
|
|
33
|
+
const n = i + 1;
|
|
34
|
+
const frame = broke === null || n <= broke ? (frames[i] ?? undefined) : undefined;
|
|
35
|
+
const base = { n, caption: describeStep(step), ...(frame ? { frame } : {}) };
|
|
36
|
+
if (broke === null || n < broke)
|
|
37
|
+
return { ...base, result: "passed" };
|
|
38
|
+
if (n > broke)
|
|
39
|
+
return { n, caption: base.caption, result: "not-run" };
|
|
40
|
+
// `broke` is set only when the outcome is not "passed".
|
|
41
|
+
const failed = outcome;
|
|
42
|
+
return { ...base, result: failed.status, reason: failed.reason, path: failed.path };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** One journey of the page, from a flow and how its replay went. */
|
|
46
|
+
export function journeyOf(flow, outcome, frames) {
|
|
47
|
+
return {
|
|
48
|
+
name: flow.name,
|
|
49
|
+
file: flow.file,
|
|
50
|
+
status: outcome.status,
|
|
51
|
+
steps: journeySteps(flow.steps, outcome, frames),
|
|
52
|
+
...(outcome.status === "passed" ? {} : { firstFailing: outcome.step }),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** One visited route, from what the crawl measured. */
|
|
56
|
+
export function visitOf(route, frame) {
|
|
57
|
+
const result = route.loadError !== undefined ? "not-loaded" : route.loginRedirect ? "sign-in" : route.status !== null && route.status >= 400 ? "http-error" : "loaded";
|
|
58
|
+
return {
|
|
59
|
+
path: route.path,
|
|
60
|
+
status: route.status,
|
|
61
|
+
result,
|
|
62
|
+
...(route.loadError !== undefined ? { reason: route.loadError } : {}),
|
|
63
|
+
...(frame ? { frame } : {}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* At most `max` frames per role, the first ones kept: the same cap a recorded
|
|
68
|
+
* session keeps (RECORD_MAX_FRAMES), held here too so the page never shows
|
|
69
|
+
* more than the run was allowed to take, whatever it is handed.
|
|
70
|
+
*/
|
|
71
|
+
export function capFrames(replay, max = RECORD_MAX_FRAMES) {
|
|
72
|
+
let leftOut = replay.framesLeftOut;
|
|
73
|
+
const roles = replay.roles.map((role) => {
|
|
74
|
+
let kept = 0;
|
|
75
|
+
const take = (item) => {
|
|
76
|
+
if (!item.frame)
|
|
77
|
+
return item;
|
|
78
|
+
if (kept < max) {
|
|
79
|
+
kept += 1;
|
|
80
|
+
return item;
|
|
81
|
+
}
|
|
82
|
+
leftOut += 1;
|
|
83
|
+
const { frame: _dropped, ...rest } = item;
|
|
84
|
+
return rest;
|
|
85
|
+
};
|
|
86
|
+
return {
|
|
87
|
+
...role,
|
|
88
|
+
visits: role.visits.map(take),
|
|
89
|
+
journeys: role.journeys.map((j) => ({ ...j, steps: j.steps.map(take) })),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
return { ...replay, roles, framesLeftOut: leftOut };
|
|
93
|
+
}
|
|
94
|
+
/** The report's redaction (memory.ts redactRoute) on every path, caption and reason the page shows. */
|
|
95
|
+
export function redactReplay(replay) {
|
|
96
|
+
return {
|
|
97
|
+
...replay,
|
|
98
|
+
roles: replay.roles.map((role) => ({
|
|
99
|
+
...role,
|
|
100
|
+
visits: role.visits.map((v) => ({ ...v, path: redactRoute(v.path), ...(v.reason !== undefined ? { reason: redactRoute(v.reason) } : {}) })),
|
|
101
|
+
journeys: role.journeys.map((j) => ({
|
|
102
|
+
...j,
|
|
103
|
+
steps: j.steps.map((s) => ({
|
|
104
|
+
...s,
|
|
105
|
+
caption: redactRoute(s.caption),
|
|
106
|
+
...(s.reason !== undefined ? { reason: redactRoute(s.reason) } : {}),
|
|
107
|
+
...(s.path !== undefined ? { path: redactRoute(s.path) } : {}),
|
|
108
|
+
})),
|
|
109
|
+
})),
|
|
110
|
+
})),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Where the video of the n-th journey run (1-based) goes beside the page. The
|
|
115
|
+
* flow's file name is reduced to a plain segment, so it decides nothing about
|
|
116
|
+
* where the file is written; the number keeps two flows' videos apart.
|
|
117
|
+
*/
|
|
118
|
+
export function journeyVideoPath(n, file) {
|
|
119
|
+
return `${REPLAY_VIDEOS_DIRNAME}/journey-${String(n).padStart(2, "0")}-${plainSegment(file.replace(/\.json$/i, ""), "flow")}.webm`;
|
|
120
|
+
}
|
|
121
|
+
/** Whether a file under replay-videos/ is a video a check wrote, and so one an earlier run's clean-up may remove. */
|
|
122
|
+
export function isJourneyVideoFile(name) {
|
|
123
|
+
return /^journey-\d{2,}-[a-z0-9._-]+\.webm$/i.test(name);
|
|
124
|
+
}
|
|
125
|
+
/** Every journey video the page links, in page order. */
|
|
126
|
+
export function replayVideos(replay) {
|
|
127
|
+
return replay.roles.flatMap((r) => r.journeys.flatMap((j) => (j.video ? [j.video] : [])));
|
|
128
|
+
}
|
|
129
|
+
/** Every frame the page shows, in page order. */
|
|
130
|
+
export function replayFrames(replay) {
|
|
131
|
+
return replay.roles.flatMap((r) => [
|
|
132
|
+
...r.visits.flatMap((v) => (v.frame ? [v.frame] : [])),
|
|
133
|
+
...r.journeys.flatMap((j) => j.steps.flatMap((s) => (s.frame ? [s.frame] : []))),
|
|
134
|
+
]);
|
|
135
|
+
}
|
|
136
|
+
const RECORDED_FRAME = /^recordings\/([a-z0-9._-]+)\/(\d{4}-[a-z0-9._-]+\.jpg)$/i;
|
|
137
|
+
/**
|
|
138
|
+
* Where a frame a session recorded (replay.ts framePath) goes beside the
|
|
139
|
+
* page, or null for anything that is not one: the path is used to write a
|
|
140
|
+
* file, so nothing else is let through.
|
|
141
|
+
*/
|
|
142
|
+
export function replayFramePath(recorded) {
|
|
143
|
+
const m = RECORDED_FRAME.exec(recorded);
|
|
144
|
+
return m ? `${REPLAY_FRAMES_DIRNAME}/${m[1]}/${m[2]}` : null;
|
|
145
|
+
}
|
|
146
|
+
/** Whether a file in a session folder under replay-frames/ is a frame a check wrote, and so one an earlier run's clean-up may remove. */
|
|
147
|
+
export function isReplayFrameFile(name) {
|
|
148
|
+
return /^\d{4}-[a-z0-9._-]+\.jpg$/i.test(name);
|
|
149
|
+
}
|
|
150
|
+
/** The session name a check's own browser records its frames under, and one for each role's. Distinct, so their frames never share a folder. */
|
|
151
|
+
export function replaySessionKey(role) {
|
|
152
|
+
return role === undefined ? "check" : `role-${plainSegment(role, "role")}`;
|
|
153
|
+
}
|
|
154
|
+
/** A commit to name in the page's header: GITHUB_SHA when it is one, else none. */
|
|
155
|
+
export function commitOf(env) {
|
|
156
|
+
const sha = (env.GITHUB_SHA ?? "").trim();
|
|
157
|
+
return /^[0-9a-f]{7,64}$/i.test(sha) ? sha.toLowerCase() : undefined;
|
|
158
|
+
}
|
|
159
|
+
const RESULT_WORD = { passed: "passed", failed: "failed", refused: "refused", "not-run": "not run" };
|
|
160
|
+
const VISIT_WORD = {
|
|
161
|
+
loaded: "loaded",
|
|
162
|
+
"http-error": "HTTP error",
|
|
163
|
+
"sign-in": "sent to sign-in",
|
|
164
|
+
"not-loaded": "did not load",
|
|
165
|
+
};
|
|
166
|
+
/** A UTC time as the page shows it: the reader's own clock is not this page's to assume. */
|
|
167
|
+
function stamp(iso) {
|
|
168
|
+
return iso.length >= 19 ? `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC` : iso;
|
|
169
|
+
}
|
|
170
|
+
const GONE = `onerror="this.parentNode.classList.add('gone')"`;
|
|
171
|
+
function frameHtml(frame, alt) {
|
|
172
|
+
if (!frame)
|
|
173
|
+
return `<p class="noframe">No frame</p>`;
|
|
174
|
+
const src = escapeHtml(frame);
|
|
175
|
+
return (`<a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="replay-frame-open"><img loading="lazy" ${GONE} src="${src}" alt="${escapeHtml(alt)}">` +
|
|
176
|
+
`<span class="gone-note">This frame is not beside this file. Frames live in the <code>${REPLAY_FRAMES_DIRNAME}/</code> folder, which travels with it.</span></a>`);
|
|
177
|
+
}
|
|
178
|
+
function stepHtml(s, anchor) {
|
|
179
|
+
const first = s.result === "failed" || s.result === "refused";
|
|
180
|
+
const detail = [s.reason, s.path ? `on ${s.path}` : ""].filter(Boolean).join(" ");
|
|
181
|
+
return (`<li class="step ${s.result}${first ? " first-failing" : ""}"${first ? ` id="${anchor}"` : ""} data-result="${s.result}">` +
|
|
182
|
+
`<div class="caption"><span class="n">${s.n}</span> <span class="what">${escapeHtml(s.caption)}</span> <span class="result r-${s.result}">${RESULT_WORD[s.result]}</span></div>` +
|
|
183
|
+
(detail ? `<p class="why">${escapeHtml(detail)}</p>` : "") +
|
|
184
|
+
(s.result === "not-run" ? "" : frameHtml(s.frame, `The page after step ${s.n}: ${s.caption}`)) +
|
|
185
|
+
`</li>`);
|
|
186
|
+
}
|
|
187
|
+
function journeyHtml(j, id) {
|
|
188
|
+
const ok = j.status === "passed";
|
|
189
|
+
const anchor = `${id}-first-failing`;
|
|
190
|
+
const badge = ok ? `<span class="badge pass">passed</span>` : `<span class="badge fail">${j.status}</span>`;
|
|
191
|
+
const jump = j.firstFailing !== undefined
|
|
192
|
+
? `<a class="jump" href="#${anchor}" data-testid="replay-first-failing-link">Step ${j.firstFailing} ${j.status}</a>`
|
|
193
|
+
: `<span class="count">${j.steps.length} step${j.steps.length === 1 ? "" : "s"}</span>`;
|
|
194
|
+
return (`<details class="journey ${ok ? "pass" : "fail"}"${ok ? "" : " open"} data-status="${j.status}"><summary data-testid="replay-journey-toggle">${badge} <b>${escapeHtml(j.name)}</b> <span class="file">${escapeHtml(j.file)}</span> ${jump}</summary>` +
|
|
195
|
+
(j.video
|
|
196
|
+
? `<figure class="video"><video controls preload="metadata" src="${escapeHtml(j.video)}" data-testid="replay-journey-video"></video>` +
|
|
197
|
+
`<figcaption>The whole journey as it ran. <a href="${escapeHtml(j.video)}" target="_blank" rel="noreferrer" data-testid="replay-video-open">Open the video</a></figcaption></figure>`
|
|
198
|
+
: "") +
|
|
199
|
+
`<ol class="steps">${j.steps.map((s) => stepHtml(s, anchor)).join("")}</ol></details>`);
|
|
200
|
+
}
|
|
201
|
+
function visitsHtml(visits) {
|
|
202
|
+
if (visits.length === 0)
|
|
203
|
+
return "";
|
|
204
|
+
const items = visits
|
|
205
|
+
.map((v) => `<li class="visit ${v.result}"><div class="caption"><span class="what">visit ${escapeHtml(v.path)}</span> <span class="result v-${v.result}">${v.status ?? "—"} · ${VISIT_WORD[v.result]}</span></div>` +
|
|
206
|
+
(v.reason ? `<p class="why">${escapeHtml(v.reason)}</p>` : "") +
|
|
207
|
+
frameHtml(v.frame, `The page at ${v.path}`) +
|
|
208
|
+
`</li>`)
|
|
209
|
+
.join("");
|
|
210
|
+
return `<details class="visits"><summary data-testid="replay-visits-toggle">Routes visited <span class="count">${visits.length}</span></summary><ol class="steps">${items}</ol></details>`;
|
|
211
|
+
}
|
|
212
|
+
function roleHtml(r, i) {
|
|
213
|
+
const failing = r.journeys.filter((j) => j.status !== "passed").length;
|
|
214
|
+
const tally = `${r.journeys.length} journey${r.journeys.length === 1 ? "" : "s"}${failing ? `, ${failing} not passed` : ""}`;
|
|
215
|
+
return (`<section class="role" id="role-${i}"><h2>${escapeHtml(r.role)} <span class="count">${r.own ? "the check's own session · " : ""}${tally}</span></h2>` +
|
|
216
|
+
visitsHtml(r.visits) +
|
|
217
|
+
(r.journeys.length > 0 ? r.journeys.map((j, k) => journeyHtml(j, `role-${i}-journey-${k}`)).join("") : `<p class="none">No journey ran as this role.</p>`) +
|
|
218
|
+
`</section>`);
|
|
219
|
+
}
|
|
220
|
+
const STYLE = `
|
|
221
|
+
:root { color-scheme: light dark; --bg:#f6f7f9; --panel:#fff; --line:#d9dde3; --text:#15181d; --muted:#5d6673; --accent:#2563eb; --pass:#047857; --pass-bg:#d1fae5; --fail:#b91c1c; --fail-bg:#fee2e2; }
|
|
222
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0e1116; --panel:#161a21; --line:#2a303a; --text:#e6e9ee; --muted:#98a2b3; --accent:#7aa2ff; --pass:#6ee7b7; --pass-bg:#063b2c; --fail:#fca5a5; --fail-bg:#3f1010; } }
|
|
223
|
+
* { box-sizing:border-box; }
|
|
224
|
+
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif; }
|
|
225
|
+
header { padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
|
|
226
|
+
header h1 { margin:0 0 4px; font-size:18px; display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
|
227
|
+
header dl { display:flex; flex-wrap:wrap; gap:4px 18px; margin:0; font-size:13px; color:var(--muted); }
|
|
228
|
+
header dt { font-weight:600; } header dd { margin:0 0 0 4px; overflow-wrap:anywhere; }
|
|
229
|
+
header .pair { display:flex; }
|
|
230
|
+
main { max-width:1000px; margin:0 auto; padding:20px 16px 64px; }
|
|
231
|
+
h2 { font-size:19px; margin:28px 0 10px; }
|
|
232
|
+
.count, .file { color:var(--muted); font-size:13px; font-weight:400; }
|
|
233
|
+
.badge { display:inline-block; padding:1px 8px; border-radius:999px; font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; }
|
|
234
|
+
.badge.pass { color:var(--pass); background:var(--pass-bg); } .badge.fail { color:var(--fail); background:var(--fail-bg); }
|
|
235
|
+
details.journey, details.visits { background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:10px 0; padding:10px 14px; }
|
|
236
|
+
details.journey.fail { border-color:var(--fail); }
|
|
237
|
+
summary { cursor:pointer; }
|
|
238
|
+
.jump { color:var(--fail); font-size:13px; margin-left:6px; }
|
|
239
|
+
ol.steps { list-style:none; margin:10px 0 0; padding:0; }
|
|
240
|
+
.step, .visit { border-left:3px solid var(--line); padding:6px 10px; margin:0 0 10px; }
|
|
241
|
+
.step.passed, .visit.loaded { border-color:var(--pass); }
|
|
242
|
+
.step.failed, .step.refused, .visit.http-error, .visit.not-loaded, .visit.sign-in { border-color:var(--fail); }
|
|
243
|
+
.step.first-failing { background:var(--fail-bg); border-left-width:6px; }
|
|
244
|
+
.step.not-run { opacity:.6; }
|
|
245
|
+
.caption { display:flex; flex-wrap:wrap; gap:6px 10px; align-items:baseline; }
|
|
246
|
+
.caption .n { font-weight:700; color:var(--muted); min-width:1.5em; }
|
|
247
|
+
.caption .what { font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
248
|
+
.result { margin-left:auto; font-size:12px; font-weight:700; }
|
|
249
|
+
.r-passed, .v-loaded { color:var(--pass); } .r-failed, .r-refused, .v-http-error, .v-not-loaded, .v-sign-in { color:var(--fail); } .r-not-run { color:var(--muted); }
|
|
250
|
+
.why { margin:4px 0; font-size:13px; overflow-wrap:anywhere; }
|
|
251
|
+
a.frame { display:block; margin:6px 0 2px; max-width:min(100%,720px); }
|
|
252
|
+
a.frame img { max-width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; }
|
|
253
|
+
.gone-note { display:none; padding:12px; border:1px dashed var(--line); border-radius:6px; color:var(--muted); font-size:12px; }
|
|
254
|
+
a.frame.gone img { display:none; } a.frame.gone .gone-note { display:block; }
|
|
255
|
+
.noframe, .none { color:var(--muted); font-size:12px; font-style:italic; margin:4px 0; }
|
|
256
|
+
.note { color:var(--muted); font-size:13px; }
|
|
257
|
+
figure.video { margin:10px 0; max-width:min(100%,720px); }
|
|
258
|
+
figure.video video { width:100%; border:1px solid var(--line); border-radius:6px; display:block; background:#000; }
|
|
259
|
+
figure.video figcaption { color:var(--muted); font-size:12px; margin-top:4px; }
|
|
260
|
+
`;
|
|
261
|
+
/** The whole page: one HTML file with no external assets or scripts; its frames sit beside it in replay-frames/. */
|
|
262
|
+
export function buildCheckReplayHtml(replay, meta) {
|
|
263
|
+
const verdict = meta.couldNotRun > 0 ? "could not run every flow" : meta.passed ? "passed" : "failed";
|
|
264
|
+
const badge = `<span class="badge ${meta.passed && meta.couldNotRun === 0 ? "pass" : "fail"}" data-testid="replay-verdict">${verdict}</span>`;
|
|
265
|
+
const pair = (term, value) => `<div class="pair"><dt>${term}</dt><dd>${escapeHtml(value)}</dd></div>`;
|
|
266
|
+
const frames = replayFrames(replay).length;
|
|
267
|
+
return `<!doctype html>
|
|
268
|
+
<html lang="en">
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="utf-8">
|
|
271
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
272
|
+
<title>SceneScout check replay — ${escapeHtml(meta.origin)}</title>
|
|
273
|
+
<style>${STYLE}</style>
|
|
274
|
+
</head>
|
|
275
|
+
<body>
|
|
276
|
+
<header>
|
|
277
|
+
<h1>SceneScout check replay ${badge}</h1>
|
|
278
|
+
<dl>${pair("App", meta.origin)}${pair("Started", stamp(meta.startedAt))}${pair("Ended", stamp(meta.endedAt))}${meta.commit ? pair("Commit", meta.commit) : ""}${pair("SceneScout", `v${meta.version}`)}${pair("Frames", String(frames))}${replayVideos(replay).length > 0 ? pair("Videos", String(replayVideos(replay).length)) : ""}</dl>
|
|
279
|
+
</header>
|
|
280
|
+
<main>
|
|
281
|
+
<p class="note">Each role, then each journey it walked, step by step, with the page as it was after the step${frames === 0 ? " when the check was recorded (--record)" : ""}. Typed values are never shown, and secrets in addresses are redacted as in the report.${replay.framesLeftOut > 0 ? ` ${replay.framesLeftOut} frame(s) past the cap of ${RECORD_MAX_FRAMES} per role are not kept; those steps show no frame.` : ""}</p>
|
|
282
|
+
${replay.roles.map(roleHtml).join("\n") || '<p class="none">Nothing was recorded.</p>'}
|
|
283
|
+
</main>
|
|
284
|
+
</body>
|
|
285
|
+
</html>
|
|
286
|
+
`;
|
|
287
|
+
}
|
package/dist/engine/check.js
CHANGED
|
@@ -169,6 +169,14 @@ function stripRefs(line) {
|
|
|
169
169
|
function withoutOrigin(text, origin) {
|
|
170
170
|
return origin ? text.split(origin).join("") : text;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* A violation's own severity where it is lower than its rule's: a refused read
|
|
174
|
+
* beside an empty state that may be about another section is medium (#413).
|
|
175
|
+
* Every other violation takes its rule's severity.
|
|
176
|
+
*/
|
|
177
|
+
function violationSeverity(v) {
|
|
178
|
+
return v.kind === "refused_empty" && v.severity === "medium" ? "medium" : undefined;
|
|
179
|
+
}
|
|
172
180
|
function violationRule(v) {
|
|
173
181
|
switch (v.kind) {
|
|
174
182
|
case "page_error":
|
|
@@ -309,7 +317,7 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
309
317
|
else if (r.elements === 0 && (r.status === null || r.status < 400))
|
|
310
318
|
add("dead-end", `${route}: 0 controls`, route);
|
|
311
319
|
for (const v of r.violations)
|
|
312
|
-
add(violationRule(v), v.detail, route, { embed: v.embed });
|
|
320
|
+
add(violationRule(v), v.detail, route, { embed: v.embed, severity: violationSeverity(v) });
|
|
313
321
|
for (const line of r.geometry) {
|
|
314
322
|
const rule = geometryRule(line);
|
|
315
323
|
if (rule)
|
|
@@ -327,7 +335,7 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
327
335
|
}
|
|
328
336
|
for (const f of flows) {
|
|
329
337
|
for (const { path, violation } of f.violations)
|
|
330
|
-
add(violationRule(violation), violation.detail, path, { embed: violation.embed, flow: f.file });
|
|
338
|
+
add(violationRule(violation), violation.detail, path, { embed: violation.embed, flow: f.file, severity: violationSeverity(violation) });
|
|
331
339
|
// A refused step is not the app's defect: the check reports it as "could not run" (refusedFlowReason).
|
|
332
340
|
if (f.outcome.status === "failed")
|
|
333
341
|
add("flow-step-failed", flowStepEvidence(f), f.outcome.path, { flow: f.file });
|
|
@@ -493,7 +501,13 @@ export const CHECK_OPTION_NAMES = [
|
|
|
493
501
|
"baselines",
|
|
494
502
|
"baseline-threshold",
|
|
495
503
|
"sarif-file-anchor",
|
|
504
|
+
"record",
|
|
505
|
+
"video",
|
|
496
506
|
];
|
|
507
|
+
/** Options that may be given alone, meaning on: `--record`, as well as `--record on` and `--record=off`. */
|
|
508
|
+
const SWITCH_OPTIONS = new Set(["record", "video"]);
|
|
509
|
+
const SWITCH_ON = ["on", "true", "1"];
|
|
510
|
+
const SWITCH_OFF = ["off", "false", "0"];
|
|
497
511
|
export const MAX_CHECK_ROUTES = 150;
|
|
498
512
|
/** Link discovery rounds: each crawl reveals the routes its pages link to. Past a few, a site is paginating rather than revealing. */
|
|
499
513
|
export const MAX_DISCOVERY_ROUNDS = 6;
|
|
@@ -514,6 +528,14 @@ export function parseCheckArgs(args, cwd) {
|
|
|
514
528
|
}
|
|
515
529
|
const eq = a.indexOf("=");
|
|
516
530
|
const name = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
531
|
+
// A switch takes the next argument only when it is on or off: `--record http://…` records, and checks that URL.
|
|
532
|
+
if (eq < 0 && SWITCH_OPTIONS.has(name)) {
|
|
533
|
+
const next = args[i + 1]?.toLowerCase();
|
|
534
|
+
if (next !== undefined && [...SWITCH_ON, ...SWITCH_OFF].includes(next))
|
|
535
|
+
i += 1;
|
|
536
|
+
flags.set(name, next !== undefined && [...SWITCH_ON, ...SWITCH_OFF].includes(next) ? next : "on");
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
517
539
|
const value = eq > 0 ? a.slice(eq + 1) : args[i + 1];
|
|
518
540
|
if (value === undefined || (eq < 0 && value.startsWith("--")))
|
|
519
541
|
return { ok: false, error: `--${name} needs a value` };
|
|
@@ -655,6 +677,14 @@ export function parseCheckArgs(args, cwd) {
|
|
|
655
677
|
const anchor = flags.has("sarif-file-anchor") ? checkSarifAnchor(flags.get("sarif-file-anchor")) : undefined;
|
|
656
678
|
if (anchor && !anchor.ok)
|
|
657
679
|
return anchor;
|
|
680
|
+
const recordRaw = flags.get("record")?.trim().toLowerCase();
|
|
681
|
+
if (recordRaw !== undefined && !SWITCH_ON.includes(recordRaw) && !SWITCH_OFF.includes(recordRaw)) {
|
|
682
|
+
return { ok: false, error: "--record is on or off, or given alone for on" };
|
|
683
|
+
}
|
|
684
|
+
const videoRaw = flags.get("video")?.trim().toLowerCase();
|
|
685
|
+
if (videoRaw !== undefined && !SWITCH_ON.includes(videoRaw) && !SWITCH_OFF.includes(videoRaw)) {
|
|
686
|
+
return { ok: false, error: "--video is on or off, or given alone for on" };
|
|
687
|
+
}
|
|
658
688
|
const resolve = (p) => resolveArgPath(cwd, p);
|
|
659
689
|
return {
|
|
660
690
|
ok: true,
|
|
@@ -681,6 +711,8 @@ export function parseCheckArgs(args, cwd) {
|
|
|
681
711
|
...(baselinesDir !== undefined ? { baselinesDir: resolve(baselinesDir) } : {}),
|
|
682
712
|
baselineThreshold: threshold.value,
|
|
683
713
|
...(anchor ? { sarifFileAnchor: anchor.value } : {}),
|
|
714
|
+
...(recordRaw !== undefined ? { record: SWITCH_ON.includes(recordRaw) } : {}),
|
|
715
|
+
...(videoRaw !== undefined && SWITCH_ON.includes(videoRaw) ? { video: true } : {}),
|
|
684
716
|
},
|
|
685
717
|
};
|
|
686
718
|
}
|
package/dist/engine/claims.js
CHANGED
|
@@ -145,6 +145,154 @@ export function classify(text) {
|
|
|
145
145
|
return "empty";
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Words in an empty-state sentence that name nothing in particular: the
|
|
150
|
+
* generic nouns for "the things", the verbs that follow them ("No results
|
|
151
|
+
* found", "No entries recorded yet") and the adjectives in front of them ("No
|
|
152
|
+
* new items", "No matching records"). Stemmed, as `stem` leaves them.
|
|
153
|
+
*/
|
|
154
|
+
const GENERIC_EMPTY_WORDS = new Set([
|
|
155
|
+
"result",
|
|
156
|
+
"item",
|
|
157
|
+
"record",
|
|
158
|
+
"row",
|
|
159
|
+
"data",
|
|
160
|
+
"entry",
|
|
161
|
+
"match",
|
|
162
|
+
"thing",
|
|
163
|
+
"one",
|
|
164
|
+
"yet",
|
|
165
|
+
"found",
|
|
166
|
+
"show",
|
|
167
|
+
"display",
|
|
168
|
+
"here",
|
|
169
|
+
"recorded",
|
|
170
|
+
"added",
|
|
171
|
+
"created",
|
|
172
|
+
"saved",
|
|
173
|
+
"available",
|
|
174
|
+
"been",
|
|
175
|
+
"matching",
|
|
176
|
+
"more",
|
|
177
|
+
"new",
|
|
178
|
+
"recent",
|
|
179
|
+
"upcoming",
|
|
180
|
+
"open",
|
|
181
|
+
"pending",
|
|
182
|
+
"other",
|
|
183
|
+
"further",
|
|
184
|
+
"api",
|
|
185
|
+
"v1",
|
|
186
|
+
"v2",
|
|
187
|
+
"v3",
|
|
188
|
+
]);
|
|
189
|
+
/** Words that end the subject of an empty-state sentence: "No results | for your search". */
|
|
190
|
+
const SUBJECT_STOP_WORDS = new Set([
|
|
191
|
+
"for",
|
|
192
|
+
"your",
|
|
193
|
+
"you",
|
|
194
|
+
"to",
|
|
195
|
+
"in",
|
|
196
|
+
"on",
|
|
197
|
+
"of",
|
|
198
|
+
"at",
|
|
199
|
+
"by",
|
|
200
|
+
"with",
|
|
201
|
+
"that",
|
|
202
|
+
"this",
|
|
203
|
+
"which",
|
|
204
|
+
"from",
|
|
205
|
+
"match",
|
|
206
|
+
"matches",
|
|
207
|
+
"yet",
|
|
208
|
+
"found",
|
|
209
|
+
"here",
|
|
210
|
+
"available",
|
|
211
|
+
"left",
|
|
212
|
+
"anymore",
|
|
213
|
+
"have",
|
|
214
|
+
"has",
|
|
215
|
+
"had",
|
|
216
|
+
"been",
|
|
217
|
+
"was",
|
|
218
|
+
"were",
|
|
219
|
+
"is",
|
|
220
|
+
"are",
|
|
221
|
+
"such",
|
|
222
|
+
]);
|
|
223
|
+
/** Crude singular form, enough to match "comments" to "/comment/" and "entries" to "/entry". */
|
|
224
|
+
function stem(word) {
|
|
225
|
+
const w = word.toLowerCase();
|
|
226
|
+
if (w.endsWith("ies") && w.length > 4)
|
|
227
|
+
return `${w.slice(0, -3)}y`;
|
|
228
|
+
if (w.endsWith("yses"))
|
|
229
|
+
return `${w.slice(0, -4)}ysis`;
|
|
230
|
+
if (w.endsWith("uses"))
|
|
231
|
+
return w.slice(0, -2);
|
|
232
|
+
if (w.endsWith("es") && /(?:ss|x|ch|sh)es$/.test(w))
|
|
233
|
+
return w.slice(0, -2);
|
|
234
|
+
if (w.endsWith("s") && !/(?:ss|us|is)$/.test(w) && w.length > 3)
|
|
235
|
+
return w.slice(0, -1);
|
|
236
|
+
return w;
|
|
237
|
+
}
|
|
238
|
+
/** A word or a path segment as stems, split on hyphens and underscores the same way on both sides. */
|
|
239
|
+
function stems(text) {
|
|
240
|
+
return text
|
|
241
|
+
.toLowerCase()
|
|
242
|
+
.split(/[^a-z0-9]+/)
|
|
243
|
+
.filter(Boolean)
|
|
244
|
+
.map(stem);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The words naming what an empty-state sentence says is missing: "comment"
|
|
248
|
+
* for "No comments yet", "corrective action" for "No corrective actions
|
|
249
|
+
* recorded yet", "order" for "There are no orders". Empty for sentences that
|
|
250
|
+
* name nothing in particular ("No results for your search", "Nothing to show",
|
|
251
|
+
* "This list is empty"). Read from where the empty-state phrase starts, so a
|
|
252
|
+
* "no" earlier in the text ("No longer available. No results.") is not taken.
|
|
253
|
+
*/
|
|
254
|
+
export function emptyStateSubject(text) {
|
|
255
|
+
const at = EMPTY_RE.exec(text)?.index ?? 0;
|
|
256
|
+
const m = /\bno\s+((?:[a-z][a-z-]*\s+){0,3}[a-z][a-z-]*)/i.exec(text.slice(at));
|
|
257
|
+
if (!m)
|
|
258
|
+
return [];
|
|
259
|
+
const words = [];
|
|
260
|
+
for (const word of m[1].split(/\s+/)) {
|
|
261
|
+
if (SUBJECT_STOP_WORDS.has(word.toLowerCase()))
|
|
262
|
+
break;
|
|
263
|
+
words.push(...stems(word));
|
|
264
|
+
}
|
|
265
|
+
return words.filter((w) => w.length > 2 && !GENERIC_EMPTY_WORDS.has(w));
|
|
266
|
+
}
|
|
267
|
+
/** Whether a request's path names one of these subject words. */
|
|
268
|
+
function pathNames(req, subject) {
|
|
269
|
+
let path;
|
|
270
|
+
try {
|
|
271
|
+
path = new URL(req.url).pathname;
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
path = req.url;
|
|
275
|
+
}
|
|
276
|
+
const segments = new Set(stems(path));
|
|
277
|
+
return subject.some((w) => segments.has(w));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Pairs one empty-state sentence with the refused reads it could be about.
|
|
281
|
+
* `loaded` are the page's reads that succeeded: positive evidence that a
|
|
282
|
+
* sentence naming what they fetched is a genuinely empty section rather than
|
|
283
|
+
* a refusal hidden behind an empty state.
|
|
284
|
+
*/
|
|
285
|
+
export function pairEmptyState(text, refusedReads, loaded) {
|
|
286
|
+
const subject = emptyStateSubject(text);
|
|
287
|
+
if (subject.length === 0)
|
|
288
|
+
return { certain: true, read: refusedReads[0] };
|
|
289
|
+
const named = refusedReads.find((r) => pathNames(r, subject));
|
|
290
|
+
if (named)
|
|
291
|
+
return { certain: true, read: named };
|
|
292
|
+
if (loaded.some((r) => pathNames(r, subject)))
|
|
293
|
+
return { unrelated: true };
|
|
294
|
+
return { certain: false, read: refusedReads[0], subject };
|
|
295
|
+
}
|
|
148
296
|
/** What counts as an open dialog: a native one or an ARIA one. */
|
|
149
297
|
const DIALOG_SEL = 'dialog[open], [role="dialog"], [role="alertdialog"]';
|
|
150
298
|
/** Page-side count of the open dialogs alone, for a caller that needs nothing else the claim scan reads. */
|
|
@@ -211,17 +359,40 @@ export function findContradictions(requests, page, before, acted) {
|
|
|
211
359
|
return [];
|
|
212
360
|
const out = [];
|
|
213
361
|
const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
362
|
+
if (reads.length > 0) {
|
|
363
|
+
// An empty-state sentence that names what is missing is evidence about
|
|
364
|
+
// requests for that thing (#413). "No comments yet" beside a refused
|
|
365
|
+
// history read, with the comments read loaded fine, is a genuinely empty
|
|
366
|
+
// comments section, not a refusal shown as an empty state.
|
|
367
|
+
// Positive evidence only: a data read the page made that succeeded. The
|
|
368
|
+
// page's own document, its images and scripts, and redirects say nothing
|
|
369
|
+
// about which section of it is empty.
|
|
370
|
+
const loaded = requests.filter((r) => (r.resourceType === "xhr" || r.resourceType === "fetch") &&
|
|
371
|
+
r.status !== null &&
|
|
372
|
+
r.status >= 200 &&
|
|
373
|
+
r.status < 300 &&
|
|
374
|
+
!WRITING_METHODS.has(r.method.toUpperCase()));
|
|
375
|
+
const pairings = page.texts.filter((_, i) => claims[i] === "empty").map((t) => ({ text: t, pairing: pairEmptyState(t, reads, loaded) }));
|
|
376
|
+
// A sentence naming what a refused read fetched points at that read; a generic one points at none in particular.
|
|
377
|
+
const certain = pairings.find((p) => "certain" in p.pairing && p.pairing.certain && emptyStateSubject(p.text).length > 0) ??
|
|
378
|
+
pairings.find((p) => "certain" in p.pairing && p.pairing.certain);
|
|
379
|
+
const possible = pairings.find((p) => "certain" in p.pairing && !p.pairing.certain);
|
|
380
|
+
const hit = certain ?? (page.emptyLists > 0 ? undefined : possible);
|
|
381
|
+
if (hit || page.emptyLists > 0) {
|
|
382
|
+
const read = hit && "read" in hit.pairing ? hit.pairing.read : reads[0];
|
|
383
|
+
const how = hit ? "an empty state" : `an empty list (${page.emptyLists})`;
|
|
384
|
+
const unsure = hit !== undefined && hit === possible;
|
|
385
|
+
out.push({
|
|
386
|
+
kind: "refused_empty",
|
|
387
|
+
detail: `${say(read)} was refused, and the page shows ${how} with no error. ` +
|
|
388
|
+
(unsure
|
|
389
|
+
? `The empty state ("${hit.text}") names something no request on the page was for, so it may be about another section; the user may be told there is nothing to see when nothing could be loaded.`
|
|
390
|
+
: `The user is told there is nothing to see when the truth is that nothing could be loaded.`) +
|
|
391
|
+
standIn(read),
|
|
392
|
+
evidence: `refused-empty ${say(read)}`,
|
|
393
|
+
...(unsure ? { severity: "medium" } : {}),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
225
396
|
}
|
|
226
397
|
// Only writes this action sent, and not the page's own infrastructure.
|
|
227
398
|
const isActionWrite = (r) => WRITING_METHODS.has(r.method.toUpperCase()) && !r.background && !INFRASTRUCTURE_WRITE_RE.test(r.url);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.20.0",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|