scenescout 3.10.0 → 3.11.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 +10 -0
- package/README.md +12 -3
- package/dist/browsers.js +20 -0
- package/dist/check-run.js +79 -5
- package/dist/cli.js +32 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +252 -19
- package/dist/engine/check.js +251 -33
- package/dist/engine/flow.js +398 -0
- package/dist/engine/memory.js +10 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +2 -2
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/check.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
14
|
import { BROWSER_ENGINES } from "../browsers.js";
|
|
15
|
+
import { flowStepEvidence } from "./flow.js";
|
|
15
16
|
import { redactSecrets } from "./memory.js";
|
|
16
17
|
import { httpErrorDetail, httpStatusOf } from "./oracles.js";
|
|
17
18
|
export const CHECK_SEVERITIES = ["high", "medium", "low"];
|
|
@@ -74,6 +75,11 @@ export const CHECK_RULES = {
|
|
|
74
75
|
"tiny-target": { severity: "low", title: "Small click target", help: "Below the 24px WCAG 2.2 target-size minimum." },
|
|
75
76
|
"clipped-text": { severity: "low", title: "Text clipped", help: "Text is wider than its box and cut off without an ellipsis." },
|
|
76
77
|
"image-aspect": { severity: "low", title: "Image distorted", help: "The rendered box does not match the image's proportions." },
|
|
78
|
+
"flow-step-failed": {
|
|
79
|
+
severity: "high",
|
|
80
|
+
title: "Saved flow broke",
|
|
81
|
+
help: "A step of a flow saved in .scenescout/flows could not be done, or what it expected was not there. The evidence names the flow, the step and what happened instead.",
|
|
82
|
+
},
|
|
77
83
|
};
|
|
78
84
|
export const CHECK_RULE_IDS = Object.keys(CHECK_RULES);
|
|
79
85
|
const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };
|
|
@@ -134,8 +140,13 @@ export function geometryRule(line) {
|
|
|
134
140
|
* Turn per-route measurements into issues, one per distinct fact. The same
|
|
135
141
|
* failing request on ten pages is one issue seen on ten routes: the shared
|
|
136
142
|
* shell's defects would otherwise bury the one page that is actually broken.
|
|
143
|
+
*
|
|
144
|
+
* Saved flows add two kinds: the step a flow broke at, and anything the
|
|
145
|
+
* oracles caught while it ran. The second kind goes through the same rules as
|
|
146
|
+
* a crawled page's, so a request that fails on load and again inside a flow is
|
|
147
|
+
* one issue, not two.
|
|
137
148
|
*/
|
|
138
|
-
export function issuesFromRoutes(routes, origin, ignore = []) {
|
|
149
|
+
export function issuesFromRoutes(routes, origin, ignore = [], flows = []) {
|
|
139
150
|
const byKey = new Map();
|
|
140
151
|
const add = (rule, evidence, route, opts = {}) => {
|
|
141
152
|
if (ignore.includes(rule))
|
|
@@ -188,6 +199,13 @@ export function issuesFromRoutes(routes, origin, ignore = []) {
|
|
|
188
199
|
for (const d of r.design)
|
|
189
200
|
add(d.rule, d.detail, d.chrome ? "(shared chrome)" : route);
|
|
190
201
|
}
|
|
202
|
+
for (const f of flows) {
|
|
203
|
+
for (const { path, violation } of f.violations)
|
|
204
|
+
add(violationRule(violation), violation.detail, path, { embed: violation.embed });
|
|
205
|
+
// A refused step is not the app's defect: the check reports it as "could not run" (refusedFlowReason).
|
|
206
|
+
if (f.outcome.status === "failed")
|
|
207
|
+
add("flow-step-failed", flowStepEvidence(f), f.outcome.path);
|
|
208
|
+
}
|
|
191
209
|
return [...byKey.values()].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity] || a.rule.localeCompare(b.rule));
|
|
192
210
|
}
|
|
193
211
|
/**
|
|
@@ -242,6 +260,18 @@ export function redactRoutes(routes) {
|
|
|
242
260
|
violations: r.violations.map((v) => ({ ...v, url: clean(v.url) })),
|
|
243
261
|
}));
|
|
244
262
|
}
|
|
263
|
+
/** The same redaction for what flows measured: the page paths and the request URLs their violations quote. */
|
|
264
|
+
export function redactFlowRuns(runs) {
|
|
265
|
+
return runs.map((f) => ({
|
|
266
|
+
...f,
|
|
267
|
+
outcome: f.outcome.status === "passed"
|
|
268
|
+
? f.outcome
|
|
269
|
+
: { ...f.outcome, path: redactRoute(f.outcome.path), did: redactRoute(f.outcome.did), reason: redactRoute(f.outcome.reason) },
|
|
270
|
+
refusedBackground: f.refusedBackground.map(redactRoute),
|
|
271
|
+
websockets: f.websockets.map(redactRoute),
|
|
272
|
+
violations: f.violations.map(({ path, violation }) => ({ path: redactRoute(path), violation: { ...violation, url: redactRoute(violation.url) } })),
|
|
273
|
+
}));
|
|
274
|
+
}
|
|
245
275
|
export function countBySeverity(issues) {
|
|
246
276
|
const counts = { high: 0, medium: 0, low: 0 };
|
|
247
277
|
for (const i of issues)
|
|
@@ -256,12 +286,36 @@ export function gateFailures(issues, failOn) {
|
|
|
256
286
|
}
|
|
257
287
|
/** Exit codes, stable for scripts: a gate failure is distinguishable from the check not running. */
|
|
258
288
|
export const EXIT = { pass: 0, gateFailed: 1, error: 2 };
|
|
289
|
+
/**
|
|
290
|
+
* The settings a project chooses for what a check may do beyond visiting
|
|
291
|
+
* pages. Each default is the one that does the least harm on an unfamiliar
|
|
292
|
+
* project: it never writes and never silently hides a result (ADR 12).
|
|
293
|
+
*/
|
|
294
|
+
export const FLOW_WRITES = ["never", "allow"];
|
|
295
|
+
export const ON_REFUSED_STEP = ["report", "stop"];
|
|
296
|
+
export const GATE_RETESTS = ["never", "high", "all"];
|
|
297
|
+
export const DEFAULT_SETTINGS = { flowWrites: "never", onRefusedStep: "report", gateRetests: "high", retest: true };
|
|
259
298
|
/**
|
|
260
299
|
* Every `--option` `scenescout check` accepts. The GitHub Action at the
|
|
261
300
|
* repository root mirrors this list input for input, and check-test fails when
|
|
262
301
|
* the two drift apart.
|
|
263
302
|
*/
|
|
264
|
-
export const CHECK_OPTION_NAMES = [
|
|
303
|
+
export const CHECK_OPTION_NAMES = [
|
|
304
|
+
"project",
|
|
305
|
+
"out",
|
|
306
|
+
"fail-on",
|
|
307
|
+
"mode",
|
|
308
|
+
"storage-state",
|
|
309
|
+
"browser",
|
|
310
|
+
"max-routes",
|
|
311
|
+
"paths",
|
|
312
|
+
"ignore",
|
|
313
|
+
"flows",
|
|
314
|
+
"retest",
|
|
315
|
+
"flow-writes",
|
|
316
|
+
"on-refused-step",
|
|
317
|
+
"gate-retests",
|
|
318
|
+
];
|
|
265
319
|
export const MAX_CHECK_ROUTES = 150;
|
|
266
320
|
export const DEFAULT_CHECK_ROUTES = 50;
|
|
267
321
|
/** Parse `scenescout check` arguments. Every mistake is a sentence, never a half-configured run. */
|
|
@@ -306,7 +360,7 @@ export function parseCheckArgs(args, cwd) {
|
|
|
306
360
|
return { ok: false, error: `--fail-on must be one of ${FAIL_ON.join(", ")}` };
|
|
307
361
|
const mode = flags.get("mode") ?? "read-only";
|
|
308
362
|
if (mode !== "observe" && mode !== "read-only") {
|
|
309
|
-
return { ok: false, error: "--mode must be observe or read-only:
|
|
363
|
+
return { ok: false, error: "--mode must be observe or read-only: the write policy for the crawl, and for saved flows under --flow-writes allow" };
|
|
310
364
|
}
|
|
311
365
|
const browser = flags.get("browser");
|
|
312
366
|
if (browser !== undefined && !BROWSER_ENGINES.includes(browser)) {
|
|
@@ -332,6 +386,25 @@ export function parseCheckArgs(args, cwd) {
|
|
|
332
386
|
const unknownRules = ignore.filter((r) => !(r in CHECK_RULES));
|
|
333
387
|
if (unknownRules.length > 0)
|
|
334
388
|
return { ok: false, error: `unknown rule(s) in --ignore: ${unknownRules.join(", ")}. Rules: ${CHECK_RULE_IDS.join(", ")}` };
|
|
389
|
+
const retest = flags.get("retest") ?? "on";
|
|
390
|
+
if (retest !== "on" && retest !== "off")
|
|
391
|
+
return { ok: false, error: "--retest must be on or off" };
|
|
392
|
+
const flows = flags.get("flows");
|
|
393
|
+
if (flows !== undefined && flows.trim() === "")
|
|
394
|
+
return { ok: false, error: "--flows needs a directory, or off" };
|
|
395
|
+
const oneOf = (name, allowed, fallback) => {
|
|
396
|
+
const v = flags.get(name) ?? fallback;
|
|
397
|
+
return allowed.includes(v) ? v : null;
|
|
398
|
+
};
|
|
399
|
+
const flowWrites = oneOf("flow-writes", FLOW_WRITES, DEFAULT_SETTINGS.flowWrites);
|
|
400
|
+
if (!flowWrites)
|
|
401
|
+
return { ok: false, error: `--flow-writes must be one of ${FLOW_WRITES.join(", ")}` };
|
|
402
|
+
const onRefusedStep = oneOf("on-refused-step", ON_REFUSED_STEP, DEFAULT_SETTINGS.onRefusedStep);
|
|
403
|
+
if (!onRefusedStep)
|
|
404
|
+
return { ok: false, error: `--on-refused-step must be one of ${ON_REFUSED_STEP.join(", ")}` };
|
|
405
|
+
const gateRetests = oneOf("gate-retests", GATE_RETESTS, DEFAULT_SETTINGS.gateRetests);
|
|
406
|
+
if (!gateRetests)
|
|
407
|
+
return { ok: false, error: `--gate-retests must be one of ${GATE_RETESTS.join(", ")}` };
|
|
335
408
|
const resolve = (p) => (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : `${cwd.replace(/[\\/]$/, "")}/${p}`);
|
|
336
409
|
return {
|
|
337
410
|
ok: true,
|
|
@@ -346,12 +419,61 @@ export function parseCheckArgs(args, cwd) {
|
|
|
346
419
|
maxRoutes,
|
|
347
420
|
...(paths ? { paths } : {}),
|
|
348
421
|
ignore: ignore,
|
|
422
|
+
...(flows !== undefined ? { flows: flows === "off" ? "off" : resolve(flows) } : {}),
|
|
423
|
+
retest: retest === "on",
|
|
424
|
+
flowWrites,
|
|
425
|
+
onRefusedStep,
|
|
426
|
+
gateRetests,
|
|
349
427
|
},
|
|
350
428
|
};
|
|
351
429
|
}
|
|
430
|
+
/** The settings part of the options, as the result records them. */
|
|
431
|
+
export function settingsOf(options) {
|
|
432
|
+
return { flowWrites: options.flowWrites, onRefusedStep: options.onRefusedStep, gateRetests: options.gateRetests, retest: options.retest };
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Why a check has no verdict because of a flow, or null. A step the write
|
|
436
|
+
* policy refused asks for something a check will not do; the flow did not
|
|
437
|
+
* run, so it neither passed nor broke, and the check exits "could not run"
|
|
438
|
+
* rather than reporting a verdict on the rest as if the flow were fine.
|
|
439
|
+
*/
|
|
440
|
+
export function refusedFlowReason(result) {
|
|
441
|
+
const refused = result.flows.filter((f) => f.outcome.status === "refused");
|
|
442
|
+
if (refused.length === 0)
|
|
443
|
+
return null;
|
|
444
|
+
const why = result.settings.flowWrites === "never"
|
|
445
|
+
? "Flows replay with --flow-writes never, so no step may send a write whatever --mode says: remove the step, or pass --flow-writes allow to replay flows under --mode"
|
|
446
|
+
: `Flows replay under --mode ${result.mode}, which refuses that write: remove the step, or leave the flow to an exploratory run`;
|
|
447
|
+
return `${refused.map(flowStepEvidence).join("; ")}. ${why}.`;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Re-tested findings that fail the gate: those still reproducing that
|
|
451
|
+
* --gate-retests covers. "possibly fixed" and "not re-tested" never do, and
|
|
452
|
+
* --fail-on never turns every gate off, this one included.
|
|
453
|
+
*/
|
|
454
|
+
export function retestGateFailures(result) {
|
|
455
|
+
const { gateRetests } = result.settings;
|
|
456
|
+
if (!result.retest || gateRetests === "never" || result.failOn === "never")
|
|
457
|
+
return [];
|
|
458
|
+
return result.retest.results.filter((r) => r.verdict === "reproduces" && (gateRetests === "all" || r.severity === "high"));
|
|
459
|
+
}
|
|
352
460
|
export function summarise(result) {
|
|
353
|
-
const
|
|
354
|
-
|
|
461
|
+
const retestsFailing = retestGateFailures(result).length;
|
|
462
|
+
const failing = gateFailures(result.issues, result.failOn).length + retestsFailing;
|
|
463
|
+
const couldNotRun = result.flows.filter((f) => f.outcome.status === "refused").length;
|
|
464
|
+
return { passed: failing === 0, counts: countBySeverity(result.issues), failing, retestsFailing, couldNotRun };
|
|
465
|
+
}
|
|
466
|
+
/** The exit code a finished check ends with. A flow that could not run outranks the gate: the verdict is incomplete. */
|
|
467
|
+
export function exitCodeOf(result) {
|
|
468
|
+
const { passed, couldNotRun } = summarise(result);
|
|
469
|
+
if (couldNotRun > 0)
|
|
470
|
+
return EXIT.error;
|
|
471
|
+
return passed ? EXIT.pass : EXIT.gateFailed;
|
|
472
|
+
}
|
|
473
|
+
/** One line of the settings, for the report and the job summary. */
|
|
474
|
+
export function describeSettings(result) {
|
|
475
|
+
const s = result.settings;
|
|
476
|
+
return `flow writes: ${s.flowWrites === "never" ? "never (flows replay under observe)" : `allow (flows replay under ${result.mode})`} · refused step: ${s.onRefusedStep} · re-tests: ${s.retest ? `on, gating ${s.gateRetests === "never" ? "nothing" : s.gateRetests === "all" ? "every one still reproducing" : "those filed high"}` : "off"}`;
|
|
355
477
|
}
|
|
356
478
|
const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
|
|
357
479
|
/**
|
|
@@ -360,9 +482,43 @@ const SARIF_LEVEL = { high: "error", medium: "warning", low: "note" };
|
|
|
360
482
|
* app (the APP base id). Fingerprints come from the evidence, not the
|
|
361
483
|
* location, so a preview deployment on a new URL does not reopen every alert.
|
|
362
484
|
*/
|
|
485
|
+
const RETEST_SARIF_RULE = {
|
|
486
|
+
id: "open-finding-reproduces",
|
|
487
|
+
name: "Open finding still reproduces",
|
|
488
|
+
shortDescription: { text: "Open finding still reproduces" },
|
|
489
|
+
help: { text: "A finding an earlier run filed and left open failed the same way when its page loaded again. It gates according to --gate-retests." },
|
|
490
|
+
defaultConfiguration: { level: "error" },
|
|
491
|
+
};
|
|
363
492
|
export function toSarif(result, toolVersion) {
|
|
364
493
|
const used = [...new Set(result.issues.map((i) => i.rule))];
|
|
365
494
|
const base = new URL(result.url);
|
|
495
|
+
const reproducing = retestGateFailures(result);
|
|
496
|
+
const refused = result.flows.filter((f) => f.outcome.status === "refused");
|
|
497
|
+
const issueResults = result.issues.map((i) => ({
|
|
498
|
+
ruleId: i.rule,
|
|
499
|
+
level: SARIF_LEVEL[i.severity],
|
|
500
|
+
message: {
|
|
501
|
+
text: `${CHECK_RULES[i.rule].title}: ${i.evidence}${i.routes.length > 1 ? ` (on ${i.routes.length} routes)` : ""}${i.embed ? ` (in an embed of ${i.embed})` : ""}`,
|
|
502
|
+
},
|
|
503
|
+
locations: i.routes.slice(0, 10).map((r) => r === "(shared chrome)"
|
|
504
|
+
? {
|
|
505
|
+
physicalLocation: { artifactLocation: { uri: "", uriBaseId: "APP" } },
|
|
506
|
+
message: { text: "the app's shared shell, on every page that renders it" },
|
|
507
|
+
}
|
|
508
|
+
: { physicalLocation: { artifactLocation: { uri: r.replace(/^\//, ""), uriBaseId: "APP" } } }),
|
|
509
|
+
partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
|
|
510
|
+
}));
|
|
511
|
+
// Only the re-tests that fail the gate are results: a reviewer reading code scanning sees what failed it.
|
|
512
|
+
const retestResults = reproducing.map((r) => ({
|
|
513
|
+
ruleId: RETEST_SARIF_RULE.id,
|
|
514
|
+
// The severity the finding was filed at, as the page rules map theirs; an unknown one reads as the worst.
|
|
515
|
+
level: SARIF_LEVEL[CHECK_SEVERITIES.includes(r.severity) ? r.severity : "high"],
|
|
516
|
+
message: {
|
|
517
|
+
text: `Open finding still reproduces: [${r.severity}] ${r.title} (${r.id}) — ${r.signatures.join(", ")} (gated by --gate-retests ${result.settings.gateRetests})`,
|
|
518
|
+
},
|
|
519
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: r.path.replace(/^\//, ""), uriBaseId: "APP" } } }],
|
|
520
|
+
partialFingerprints: { "scenescoutCheck/v1": createHash("sha256").update(`retest\u0000${r.id}`).digest("hex").slice(0, 32) },
|
|
521
|
+
}));
|
|
366
522
|
return {
|
|
367
523
|
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
368
524
|
version: "2.1.0",
|
|
@@ -373,30 +529,31 @@ export function toSarif(result, toolVersion) {
|
|
|
373
529
|
name: "SceneScout",
|
|
374
530
|
version: toolVersion,
|
|
375
531
|
informationUri: "https://github.com/brunoboto96/SceneScout",
|
|
376
|
-
rules:
|
|
377
|
-
id
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
532
|
+
rules: [
|
|
533
|
+
...used.map((id) => ({
|
|
534
|
+
id,
|
|
535
|
+
name: CHECK_RULES[id].title,
|
|
536
|
+
shortDescription: { text: CHECK_RULES[id].title },
|
|
537
|
+
help: { text: CHECK_RULES[id].help },
|
|
538
|
+
defaultConfiguration: { level: SARIF_LEVEL[CHECK_RULES[id].severity] },
|
|
539
|
+
})),
|
|
540
|
+
...(reproducing.length > 0 ? [RETEST_SARIF_RULE] : []),
|
|
541
|
+
],
|
|
383
542
|
},
|
|
384
543
|
},
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
544
|
+
// A flow that could not run is not a result about the app: it is the tool saying its run was incomplete.
|
|
545
|
+
invocations: [
|
|
546
|
+
{
|
|
547
|
+
executionSuccessful: refused.length === 0,
|
|
548
|
+
toolExecutionNotifications: refused.map((f) => ({
|
|
549
|
+
level: "error",
|
|
550
|
+
descriptor: { id: "flow-could-not-run" },
|
|
551
|
+
message: { text: `Could not run: ${flowStepEvidence(f)}` },
|
|
552
|
+
})),
|
|
391
553
|
},
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
message: { text: "the app's shared shell, on every page that renders it" },
|
|
396
|
-
}
|
|
397
|
-
: { physicalLocation: { artifactLocation: { uri: r.replace(/^\//, ""), uriBaseId: "APP" } } }),
|
|
398
|
-
partialFingerprints: { "scenescoutCheck/v1": i.fingerprint },
|
|
399
|
-
})),
|
|
554
|
+
],
|
|
555
|
+
originalUriBaseIds: { APP: { uri: `${base.origin}/` } },
|
|
556
|
+
results: [...issueResults, ...retestResults],
|
|
400
557
|
},
|
|
401
558
|
],
|
|
402
559
|
};
|
|
@@ -425,17 +582,23 @@ function routeList(routes) {
|
|
|
425
582
|
}
|
|
426
583
|
/** The human report: the verdict first, then what failed it, then everything else. */
|
|
427
584
|
export function formatCheck(result) {
|
|
428
|
-
const { passed, counts, failing } = summarise(result);
|
|
585
|
+
const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
|
|
429
586
|
const lines = [];
|
|
430
587
|
lines.push(`# SceneScout check`);
|
|
431
588
|
lines.push("");
|
|
432
589
|
lines.push(`${result.url} · ${result.routes.length} route(s) · mode ${result.mode} · gate: ${result.failOn === "never" ? "report only" : `fail on ${result.failOn}${result.failOn === "low" ? "" : " or worse"}`}`);
|
|
433
590
|
lines.push("");
|
|
434
591
|
const unaudited = unauditedRoutes(result.routes).length;
|
|
435
|
-
|
|
436
|
-
?
|
|
437
|
-
:
|
|
592
|
+
const failingText = retestsFailing > 0
|
|
593
|
+
? `${failing} failing the gate (${failing - retestsFailing} issue(s), ${retestsFailing} re-tested finding(s))`
|
|
594
|
+
: `${failing} issue(s) at the gate's severity`;
|
|
595
|
+
lines.push((couldNotRun > 0 ? `**COULD NOT RUN** — ${couldNotRun} flow(s) had a step refused (see Flows); the rest ` : "") +
|
|
596
|
+
(passed
|
|
597
|
+
? `${couldNotRun > 0 ? "passed" : "**PASSED**"} — ${counts.high} high · ${counts.medium} medium · ${counts.low} low`
|
|
598
|
+
: `${couldNotRun > 0 ? "failed" : "**FAILED**"} — ${failingText} · ${counts.high} high · ${counts.medium} medium · ${counts.low} low`) +
|
|
438
599
|
(unaudited > 0 ? ` · design not measured on ${unaudited} route(s)` : ""));
|
|
600
|
+
// Right under the verdict: what a green check was allowed to do is part of what it means.
|
|
601
|
+
lines.push("", `Settings — ${describeSettings(result)}`);
|
|
439
602
|
for (const sev of CHECK_SEVERITIES) {
|
|
440
603
|
const of = result.issues.filter((i) => i.severity === sev);
|
|
441
604
|
if (of.length === 0)
|
|
@@ -456,21 +619,65 @@ export function formatCheck(result) {
|
|
|
456
619
|
if (result.unvisited.length > 0) {
|
|
457
620
|
lines.push("", `Not visited (over --max-routes): ${result.unvisited.slice(0, 20).map(code).join(", ")}${result.unvisited.length > 20 ? " …" : ""}`);
|
|
458
621
|
}
|
|
622
|
+
if (result.flows.length > 0 || result.skippedFlows.length > 0) {
|
|
623
|
+
lines.push("", `## Flows (${result.flows.length})`, "");
|
|
624
|
+
for (const f of result.flows) {
|
|
625
|
+
const o = f.outcome;
|
|
626
|
+
lines.push(o.status === "passed"
|
|
627
|
+
? `- ✓ ${cell(f.name)} (${code(f.file)}): ${f.steps} step(s) passed`
|
|
628
|
+
: o.status === "refused"
|
|
629
|
+
? `- ⊘ ${cell(f.name)} (${code(f.file)}): could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
|
|
630
|
+
: `- ✗ ${cell(f.name)} (${code(f.file)}): step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
|
|
631
|
+
if (f.refusedBackground.length > 0)
|
|
632
|
+
lines.push(` - refused background requests (beacons and pings, not charged to a step): ${f.refusedBackground.map(code).join(", ")}`);
|
|
633
|
+
if (f.websockets.length > 0)
|
|
634
|
+
lines.push(` - WebSocket connections opened (not covered by the write rule): ${f.websockets.map(code).join(", ")}`);
|
|
635
|
+
}
|
|
636
|
+
for (const s of result.skippedFlows)
|
|
637
|
+
lines.push(`- skipped ${code(s.file)}: ${cell(s.reason)}`);
|
|
638
|
+
}
|
|
639
|
+
if (result.retest && result.retest.open > 0) {
|
|
640
|
+
const { open, results } = result.retest;
|
|
641
|
+
lines.push("", `## Open findings re-tested (${results.length} of ${open})`, "");
|
|
642
|
+
const says = {
|
|
643
|
+
reproduces: "still reproduces",
|
|
644
|
+
"possibly-fixed": "possibly fixed: the page loaded and the request did not fail",
|
|
645
|
+
"not-reached": "not re-tested",
|
|
646
|
+
};
|
|
647
|
+
const gating = new Set(retestGateFailures(result));
|
|
648
|
+
for (const r of results) {
|
|
649
|
+
lines.push(`- [${r.severity}] ${cell(r.title)} (${r.id}) on ${code(r.path)}: ${says[r.verdict]}${r.note ? ` (${r.note})` : ""}${gating.has(r) ? " — **fails the gate**" : ""} — ${r.signatures.map(code).join(", ")}`);
|
|
650
|
+
}
|
|
651
|
+
if (result.retest.extraPages) {
|
|
652
|
+
lines.push("", `${result.retest.extraPages} page(s) were loaded only to re-test these findings; they are not in the routes above and no page rule was applied to them.`);
|
|
653
|
+
}
|
|
654
|
+
if (open > results.length) {
|
|
655
|
+
lines.push("", `${open - results.length} open finding(s) need an interaction to reproduce, or name no failed GET, so a check cannot re-test them; \`scout_verify\` in an exploratory run can.`);
|
|
656
|
+
}
|
|
657
|
+
lines.push("", `_Re-tests gate by --gate-retests ${result.settings.gateRetests}: ${result.settings.gateRetests === "never"
|
|
658
|
+
? "none of them"
|
|
659
|
+
: result.settings.gateRetests === "all"
|
|
660
|
+
? "every finding still reproducing"
|
|
661
|
+
: "a finding filed high that still reproduces"} fails the gate; "possibly fixed" and "not re-tested" never do. A check never writes the project's memory, so nothing is resolved here._`);
|
|
662
|
+
}
|
|
459
663
|
if (result.ignored.length > 0)
|
|
460
664
|
lines.push("", `Rules ignored by --ignore: ${result.ignored.join(", ")}`);
|
|
461
|
-
lines.push("",
|
|
665
|
+
lines.push("", result.flows.length > 0
|
|
666
|
+
? "_A check visits pages, measures what loads and replays the flows saved for it. It does not explore, fill forms of its own accord or compare roles; an exploratory run does that._"
|
|
667
|
+
: "_A check visits pages and measures what loads. It does not fill forms, click through flows or compare roles; an exploratory run does that. Flows saved in .scenescout/flows are replayed._");
|
|
462
668
|
return lines.join("\n") + "\n";
|
|
463
669
|
}
|
|
464
670
|
/** The machine summary: stable keys for scripts and follow-up jobs. */
|
|
465
671
|
export function toSummaryJson(result, toolVersion) {
|
|
466
|
-
const { passed, counts, failing } = summarise(result);
|
|
672
|
+
const { passed, counts, failing, retestsFailing, couldNotRun } = summarise(result);
|
|
467
673
|
return {
|
|
468
674
|
tool: "scenescout-check",
|
|
469
675
|
version: toolVersion,
|
|
470
676
|
url: result.url,
|
|
471
677
|
generatedAt: result.generatedAt,
|
|
472
678
|
mode: result.mode,
|
|
473
|
-
gate: { failOn: result.failOn, passed, failing },
|
|
679
|
+
gate: { failOn: result.failOn, passed, failing, retestsFailing, couldNotRun },
|
|
680
|
+
settings: result.settings,
|
|
474
681
|
counts,
|
|
475
682
|
routes: result.routes.map((r) => ({
|
|
476
683
|
path: r.path,
|
|
@@ -482,6 +689,17 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
482
689
|
})),
|
|
483
690
|
unvisited: result.unvisited,
|
|
484
691
|
ignored: result.ignored,
|
|
692
|
+
flows: result.flows.map((f) => ({
|
|
693
|
+
name: f.name,
|
|
694
|
+
file: f.file,
|
|
695
|
+
steps: f.steps,
|
|
696
|
+
status: f.outcome.status,
|
|
697
|
+
...(f.outcome.status === "passed" ? {} : { step: f.outcome.step, did: f.outcome.did, reason: f.outcome.reason, path: f.outcome.path }),
|
|
698
|
+
refusedBackground: f.refusedBackground,
|
|
699
|
+
websockets: f.websockets,
|
|
700
|
+
})),
|
|
701
|
+
skippedFlows: result.skippedFlows,
|
|
702
|
+
retest: result.retest,
|
|
485
703
|
issues: result.issues,
|
|
486
704
|
};
|
|
487
705
|
}
|