scenescout 3.9.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 +25 -0
- package/README.md +61 -3
- package/dist/browsers.js +32 -0
- package/dist/check-run.js +150 -0
- package/dist/cli.js +97 -6
- package/dist/engine/bench.js +154 -17
- package/dist/engine/browser.js +526 -38
- package/dist/engine/check.js +705 -0
- package/dist/engine/collector.js +94 -18
- package/dist/engine/design.js +24 -8
- package/dist/engine/flow.js +398 -0
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +64 -3
- package/dist/engine/oracles.js +79 -2
- package/dist/engine/pace.js +14 -1
- package/dist/engine/verify.js +119 -0
- package/dist/mcp-server.js +40 -9
- package/package.json +8 -4
- package/skills/scenescout/SKILL.md +3 -3
package/dist/engine/collector.js
CHANGED
|
@@ -19,21 +19,12 @@ export const VISIBLE_SRC = `(el) => {
|
|
|
19
19
|
return style.visibility !== "hidden" && style.display !== "none";
|
|
20
20
|
}`;
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
|
|
28
|
-
// Declared above the collector script because that script interpolates it.
|
|
29
|
-
/**
|
|
30
|
-
* Page-side interactable collector. Shipped as a STRING, not a function:
|
|
31
|
-
* loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
|
|
32
|
-
* serialized functions inside the browser, where the helper doesn't exist.
|
|
33
|
-
* A string expression is immune to any build/loader instrumentation.
|
|
22
|
+
* The collector's element path, as page-side source. Exported so a page-side
|
|
23
|
+
* probe that names an element (forms.ts) names it with the SAME path the
|
|
24
|
+
* snapshot listed it under, and the engine can map it back to that element's
|
|
25
|
+
* coverage key.
|
|
34
26
|
*/
|
|
35
|
-
export const
|
|
36
|
-
const xpathOf = (el) => {
|
|
27
|
+
export const XPATH_OF_SRC = `(el) => {
|
|
37
28
|
const parts = [];
|
|
38
29
|
let node = el;
|
|
39
30
|
while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== "html") {
|
|
@@ -47,9 +38,13 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
47
38
|
node = node.parentElement;
|
|
48
39
|
}
|
|
49
40
|
return "/html/" + parts.join("/");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
|
|
41
|
+
}`;
|
|
42
|
+
/**
|
|
43
|
+
* The collector's accessible name, as page-side source. Exported for the same
|
|
44
|
+
* reason as XPATH_OF_SRC: a page-side probe that names an element (forms.ts)
|
|
45
|
+
* must name it exactly as the snapshot did, or the two cannot be compared.
|
|
46
|
+
*/
|
|
47
|
+
export const ACCESSIBLE_NAME_SRC = `(el) => {
|
|
53
48
|
const aria = el.getAttribute("aria-label");
|
|
54
49
|
if (aria) return aria.trim();
|
|
55
50
|
// aria-labelledby before any fallback: it is the standard way to name an
|
|
@@ -83,6 +78,51 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
83
78
|
}
|
|
84
79
|
const text = el.innerText || el.textContent || "";
|
|
85
80
|
return text.trim().replace(/\\s+/g, " ").slice(0, 80);
|
|
81
|
+
}`;
|
|
82
|
+
/**
|
|
83
|
+
* Anything that plausibly presents as a modal/dialog panel. Deliberately wider
|
|
84
|
+
* than the ARIA set: a hand-rolled role-less modal must still count as "an
|
|
85
|
+
* overlay is up", or the scroll-lock oracle files a false leaked-lock finding
|
|
86
|
+
* against every healthy modal that locks the page behind it.
|
|
87
|
+
*/
|
|
88
|
+
export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
|
|
89
|
+
// Declared above the collector script because that script interpolates it.
|
|
90
|
+
/**
|
|
91
|
+
* Page-side interactable collector. Shipped as a STRING, not a function:
|
|
92
|
+
* loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
|
|
93
|
+
* serialized functions inside the browser, where the helper doesn't exist.
|
|
94
|
+
* A string expression is immune to any build/loader instrumentation.
|
|
95
|
+
*/
|
|
96
|
+
export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
97
|
+
const xpathOf = ${XPATH_OF_SRC};
|
|
98
|
+
const visible = ${VISIBLE_SRC};
|
|
99
|
+
const accessibleName = ${ACCESSIBLE_NAME_SRC};
|
|
100
|
+
// Whether a field's accessibleName came from something that is not a label. The
|
|
101
|
+
// name falls back to the placeholder, then the name attribute, then the type,
|
|
102
|
+
// so every field can be told apart and targeted, and it is half of the
|
|
103
|
+
// element's key, which project memory keeps across runs: it stays as it is.
|
|
104
|
+
// This says what the fallback hides. A real label is aria-label,
|
|
105
|
+
// aria-labelledby, a <label> (for= or wrapping, read through el.labels),
|
|
106
|
+
// title, or for a button-like input its value or the browser's default text.
|
|
107
|
+
// "placeholder" when the placeholder is all there is, "fallback" when not even
|
|
108
|
+
// that, null when the field is labelled or is not a field.
|
|
109
|
+
const nameFrom = (el) => {
|
|
110
|
+
const tag = el.tagName.toLowerCase();
|
|
111
|
+
if (tag !== "input" && tag !== "textarea") return null;
|
|
112
|
+
if ((el.getAttribute("aria-label") || "").trim()) return null;
|
|
113
|
+
const labelledBy = (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean);
|
|
114
|
+
if (labelledBy.some((id) => { const n = document.getElementById(id); return n && (n.textContent || "").trim(); })) return null;
|
|
115
|
+
for (const label of Array.from(el.labels || [])) {
|
|
116
|
+
// A wrapping label's text includes a textarea's own content; that is not a label.
|
|
117
|
+
const own = label.contains(el) ? el.textContent || "" : "";
|
|
118
|
+
if ((label.textContent || "").replace(own, "").trim()) return null;
|
|
119
|
+
}
|
|
120
|
+
if ((el.getAttribute("title") || "").trim()) return null;
|
|
121
|
+
const type = tag === "input" ? el.type : "";
|
|
122
|
+
if (type === "submit" || type === "reset") return null;
|
|
123
|
+
if (type === "button" && (el.value || "").trim()) return null;
|
|
124
|
+
if (type === "image" && (el.getAttribute("alt") || "").trim()) return null;
|
|
125
|
+
return (el.getAttribute("placeholder") || "").trim() ? "placeholder" : "fallback";
|
|
86
126
|
};
|
|
87
127
|
const selector =
|
|
88
128
|
'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], ' +
|
|
@@ -257,6 +297,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
257
297
|
tag,
|
|
258
298
|
role,
|
|
259
299
|
name: accessibleName(el),
|
|
300
|
+
nameFrom: nameFrom(el),
|
|
260
301
|
testid: el.getAttribute("data-testid"),
|
|
261
302
|
xpath: xpathOf(el),
|
|
262
303
|
disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
|
|
@@ -283,10 +324,45 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
283
324
|
* as an accessibility defect — twice in one measured run.
|
|
284
325
|
*/
|
|
285
326
|
const LIVE_REGION_ROLES = new Set(["status", "alert", "log", "timer", "marquee"]);
|
|
286
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* Whether an element is an unnamed control, as opposed to a live region with
|
|
329
|
+
* nothing in it yet. A field whose shown name is only its name attribute or
|
|
330
|
+
* type is unnamed too: the collector fills those in so the field can be
|
|
331
|
+
* targeted, but nothing announces them.
|
|
332
|
+
*/
|
|
287
333
|
export function missingName(el) {
|
|
334
|
+
if (el.nameFrom === "fallback")
|
|
335
|
+
return true;
|
|
288
336
|
return !el.name && !LIVE_REGION_ROLES.has(el.role);
|
|
289
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Whether a field's only label is its placeholder. Kept apart from missingName
|
|
340
|
+
* because browsers do announce a placeholder when nothing else names the
|
|
341
|
+
* field, so it is not nameless; but the text is gone as soon as the user
|
|
342
|
+
* types, and it was never a label. The shown name must be non-empty: a blank
|
|
343
|
+
* aria-label ends the name before the placeholder is reached, so that field is
|
|
344
|
+
* unnamed (missingName) and only that.
|
|
345
|
+
*/
|
|
346
|
+
export function placeholderOnly(el) {
|
|
347
|
+
return el.nameFrom === "placeholder" && !!el.name;
|
|
348
|
+
}
|
|
349
|
+
/** How a crawl names a control in the check's evidence: by role and test id, or role and XPath. */
|
|
350
|
+
export function describeControl(el) {
|
|
351
|
+
return `${el.role}${el.testid ? ` [testid=${el.testid}]` : ` at ${el.xpath}`}`;
|
|
352
|
+
}
|
|
353
|
+
/** A placeholder-only field's evidence: the control, then its placeholder, capped so a paragraph of hint text stays one line. */
|
|
354
|
+
export function placeholderEvidence(el) {
|
|
355
|
+
const text = el.name.length > 80 ? `${el.name.slice(0, 79)}…` : el.name;
|
|
356
|
+
return `${describeControl(el)} "${text}"`;
|
|
357
|
+
}
|
|
358
|
+
/** The snapshot's flag for a field with no label, or null when it has one. */
|
|
359
|
+
export function labelFlag(el) {
|
|
360
|
+
if (placeholderOnly(el))
|
|
361
|
+
return "no label: placeholder only";
|
|
362
|
+
if (el.nameFrom)
|
|
363
|
+
return "no label";
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
290
366
|
/** How the snapshot shows an element's name: an empty live region says so rather than reading as an unnamed control. */
|
|
291
367
|
export function displayName(el) {
|
|
292
368
|
if (el.name)
|
package/dist/engine/design.js
CHANGED
|
@@ -232,6 +232,9 @@ function hueOf([r, g, b]) {
|
|
|
232
232
|
return Math.round((h * 60 + 360) % 360);
|
|
233
233
|
}
|
|
234
234
|
const label = (r) => (r.testid ? `[${r.testid}]` : `<${r.tag}> "${r.text.slice(0, 30) || "(no text)"}"`);
|
|
235
|
+
/** One wording for a small target, page or shell alike: the shell section's, so its prose is unchanged. */
|
|
236
|
+
const tinyTargetDetail = (r) => `${label(r)} — ${Math.round(r.rect.w)}×${Math.round(r.rect.h)}px tap target`;
|
|
237
|
+
const clippedTextDetail = (r) => `${label(r)} — text is clipped by its container`;
|
|
235
238
|
/**
|
|
236
239
|
* Stable identity for one styled element, used to recognise the SAME component
|
|
237
240
|
* across routes.
|
|
@@ -293,7 +296,7 @@ function tooSmall(r) {
|
|
|
293
296
|
export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
294
297
|
const { records: allRecords, page } = payload;
|
|
295
298
|
if (allRecords.length === 0) {
|
|
296
|
-
return { report: "DESIGN AUDIT: no visible styled elements found (page empty or not hydrated).", score: null, signatures: [] };
|
|
299
|
+
return { report: "DESIGN AUDIT: no visible styled elements found (page empty or not hydrated).", score: null, signatures: [], defects: [] };
|
|
297
300
|
}
|
|
298
301
|
const signatures = allRecords.map(styleSignature);
|
|
299
302
|
const isChrome = (r) => chromeKeys.has(styleSignature(r));
|
|
@@ -302,7 +305,7 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
302
305
|
// set so no rule can accidentally reach past the page's own content.
|
|
303
306
|
const records = chromeKeys.size > 0 ? allRecords.filter((r) => !isChrome(r)) : allRecords;
|
|
304
307
|
if (records.length === 0) {
|
|
305
|
-
return { report: "DESIGN AUDIT: this page is entirely shared layout chrome — nothing page-specific to score.", score: null, signatures };
|
|
308
|
+
return { report: "DESIGN AUDIT: this page is entirely shared layout chrome — nothing page-specific to score.", score: null, signatures, defects: [] };
|
|
306
309
|
}
|
|
307
310
|
const sections = [];
|
|
308
311
|
// ---- 1. CONTRAST (WCAG): normal text needs 4.5:1, large text (≥24px, or ≥18.7px bold) needs 3:1.
|
|
@@ -690,12 +693,10 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
690
693
|
// shell — sub-minimum tap targets and clipped labels in a sidebar would be
|
|
691
694
|
// seen by no page at all.
|
|
692
695
|
const chromeSection = [];
|
|
696
|
+
const chromeDefects = [];
|
|
693
697
|
if (chromeRecords.length > 0) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
...chromeRecords.filter((r) => r.interactive && tooSmall(r)).map((r) => `${label(r)} — ${Math.round(r.rect.w)}×${Math.round(r.rect.h)}px tap target`),
|
|
697
|
-
...chromeRecords.filter((r) => r.clipped && r.textLen > 0).map((r) => `${label(r)} — text is clipped by its container`),
|
|
698
|
-
];
|
|
698
|
+
chromeDefects.push(...contrastFailures(chromeRecords).map((detail) => ({ rule: "contrast", detail, chrome: true })), ...chromeRecords.filter((r) => r.interactive && tooSmall(r)).map((r) => ({ rule: "tiny-target", detail: tinyTargetDetail(r), chrome: true })), ...chromeRecords.filter((r) => r.clipped && r.textLen > 0).map((r) => ({ rule: "clipped-text", detail: clippedTextDetail(r), chrome: true })));
|
|
699
|
+
const chromeIssues = chromeDefects.map((d) => d.detail);
|
|
699
700
|
if (chromeIssues.length > 0) {
|
|
700
701
|
chromeSection.push(`SHARED CHROME (${chromeRecords.length} shell elements, excluded from this page's score and reported here instead):\n` +
|
|
701
702
|
[...new Set(chromeIssues)]
|
|
@@ -712,5 +713,20 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
712
713
|
`\n\nJudge with product context: ⚠ lines are measurable defects; → lines are craft suggestions (how the page could be BETTER, not just what's broken). ` +
|
|
713
714
|
`Not every flag is a bug — dense data tables legitimately use small targets. The score is a comparator across pages and runs, not an absolute verdict. ` +
|
|
714
715
|
`File real defects with scout_finding (category "visual"/"a11y") and genuine improvement opportunities as severity-low "ux-polish", quoting the concrete numbers.`;
|
|
715
|
-
|
|
716
|
+
const defects = [
|
|
717
|
+
...contrastFails.map((detail) => ({ rule: "contrast", detail })),
|
|
718
|
+
// Worded exactly as the shell's (below), so one control is one fact whether or not it is known chrome yet.
|
|
719
|
+
...tiny.map((r) => ({ rule: "tiny-target", detail: tinyTargetDetail(r) })),
|
|
720
|
+
...clipped.filter((r) => r.textLen > 0).map((r) => ({ rule: "clipped-text", detail: clippedTextDetail(r) })),
|
|
721
|
+
...focusless.map((f) => ({ rule: "focus-indicator", detail: `${f.label} — no visible focus indicator` })),
|
|
722
|
+
...distorted.map(({ img, off }) => ({
|
|
723
|
+
rule: "image-aspect",
|
|
724
|
+
detail: `${img.label} — rendered ${img.rw}×${img.rh} vs natural ${img.nw}×${img.nh} (aspect off by ${Math.round(off * 100)}%)`,
|
|
725
|
+
})),
|
|
726
|
+
...(page.scrollW > viewport.width + 8
|
|
727
|
+
? [{ rule: "horizontal-scroll", detail: `content ${page.scrollW}px wide in a ${viewport.width}px viewport` }]
|
|
728
|
+
: []),
|
|
729
|
+
...chromeDefects,
|
|
730
|
+
];
|
|
731
|
+
return { report, score, signatures, defects };
|
|
716
732
|
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved flows: the steps `scenescout check` replays, as data.
|
|
3
|
+
*
|
|
4
|
+
* A flow is a short script a person or an agent wrote down once because it
|
|
5
|
+
* matters that it keeps working: open a page, click a control, see a result.
|
|
6
|
+
* The check replays every flow in `.scenescout/flows/*.json` after its crawl,
|
|
7
|
+
* with no model, so a flow that passed yesterday and breaks today fails the
|
|
8
|
+
* gate naming the flow and the step (ADR 12).
|
|
9
|
+
*
|
|
10
|
+
* The steps are `scout_run_plan`'s steps, so a plan that worked in an
|
|
11
|
+
* exploratory run can be saved as it is, plus three assertions a plan has no
|
|
12
|
+
* need for: text is visible, the URL matches, a request answered with a status.
|
|
13
|
+
*
|
|
14
|
+
* Everything here is pure: the schema and its error messages, the target
|
|
15
|
+
* grammar, and the rules that decide whether a URL or a request matched. The
|
|
16
|
+
* browser half is BrowserEngine.replayFlow, which only acts and reports.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { beaconResourceType, BROWSER_ENGINES } from "../browsers.js";
|
|
22
|
+
import { MEMORY_DIRNAME, SELF_IGNORE_KEEP } from "./memory.js";
|
|
23
|
+
/** Most steps one flow may hold. A longer flow is several flows, and each would fail more usefully on its own. */
|
|
24
|
+
export const MAX_FLOW_STEPS = 50;
|
|
25
|
+
/** Most flows one check replays. */
|
|
26
|
+
export const MAX_FLOWS = 50;
|
|
27
|
+
/** How long a step waits for its target, text, URL or request before it counts as broken. */
|
|
28
|
+
export const FLOW_STEP_TIMEOUT_MS = 5000;
|
|
29
|
+
/**
|
|
30
|
+
* How long a flow waits after its last step for a write that step set off
|
|
31
|
+
* late (a save on a timer, a debounced autosave), before it counts as passed.
|
|
32
|
+
*/
|
|
33
|
+
export const FLOW_AFTER_LAST_STEP_MS = 750;
|
|
34
|
+
/** Where a project keeps its flows, relative to the project directory. */
|
|
35
|
+
export const FLOWS_SUBDIR = path.join(MEMORY_DIRNAME, SELF_IGNORE_KEEP);
|
|
36
|
+
export const TARGET_HELP = 'testid=…, text=…, label=… or role=<role>[name="…"]';
|
|
37
|
+
/**
|
|
38
|
+
* The ARIA roles a `role=` target may name (WAI-ARIA 1.2, as Playwright's
|
|
39
|
+
* getByRole accepts them). A misspelt role matches nothing, so a flow using
|
|
40
|
+
* one would only ever fail at replay; it is refused when the file is read.
|
|
41
|
+
*/
|
|
42
|
+
export const ARIA_ROLE_LIST = [
|
|
43
|
+
"alert",
|
|
44
|
+
"alertdialog",
|
|
45
|
+
"application",
|
|
46
|
+
"article",
|
|
47
|
+
"banner",
|
|
48
|
+
"blockquote",
|
|
49
|
+
"button",
|
|
50
|
+
"caption",
|
|
51
|
+
"cell",
|
|
52
|
+
"checkbox",
|
|
53
|
+
"code",
|
|
54
|
+
"columnheader",
|
|
55
|
+
"combobox",
|
|
56
|
+
"complementary",
|
|
57
|
+
"contentinfo",
|
|
58
|
+
"definition",
|
|
59
|
+
"deletion",
|
|
60
|
+
"dialog",
|
|
61
|
+
"directory",
|
|
62
|
+
"document",
|
|
63
|
+
"emphasis",
|
|
64
|
+
"feed",
|
|
65
|
+
"figure",
|
|
66
|
+
"form",
|
|
67
|
+
"generic",
|
|
68
|
+
"grid",
|
|
69
|
+
"gridcell",
|
|
70
|
+
"group",
|
|
71
|
+
"heading",
|
|
72
|
+
"img",
|
|
73
|
+
"insertion",
|
|
74
|
+
"link",
|
|
75
|
+
"list",
|
|
76
|
+
"listbox",
|
|
77
|
+
"listitem",
|
|
78
|
+
"log",
|
|
79
|
+
"main",
|
|
80
|
+
"marquee",
|
|
81
|
+
"math",
|
|
82
|
+
"meter",
|
|
83
|
+
"menu",
|
|
84
|
+
"menubar",
|
|
85
|
+
"menuitem",
|
|
86
|
+
"menuitemcheckbox",
|
|
87
|
+
"menuitemradio",
|
|
88
|
+
"navigation",
|
|
89
|
+
"none",
|
|
90
|
+
"note",
|
|
91
|
+
"option",
|
|
92
|
+
"paragraph",
|
|
93
|
+
"presentation",
|
|
94
|
+
"progressbar",
|
|
95
|
+
"radio",
|
|
96
|
+
"radiogroup",
|
|
97
|
+
"region",
|
|
98
|
+
"row",
|
|
99
|
+
"rowgroup",
|
|
100
|
+
"rowheader",
|
|
101
|
+
"scrollbar",
|
|
102
|
+
"search",
|
|
103
|
+
"searchbox",
|
|
104
|
+
"separator",
|
|
105
|
+
"slider",
|
|
106
|
+
"spinbutton",
|
|
107
|
+
"status",
|
|
108
|
+
"strong",
|
|
109
|
+
"subscript",
|
|
110
|
+
"superscript",
|
|
111
|
+
"switch",
|
|
112
|
+
"tab",
|
|
113
|
+
"table",
|
|
114
|
+
"tablist",
|
|
115
|
+
"tabpanel",
|
|
116
|
+
"term",
|
|
117
|
+
"textbox",
|
|
118
|
+
"time",
|
|
119
|
+
"timer",
|
|
120
|
+
"toolbar",
|
|
121
|
+
"tooltip",
|
|
122
|
+
"tree",
|
|
123
|
+
"treegrid",
|
|
124
|
+
"treeitem",
|
|
125
|
+
];
|
|
126
|
+
export const ARIA_ROLES = new Set(ARIA_ROLE_LIST);
|
|
127
|
+
export const ARIA_ROLES_MATCH_PLAYWRIGHT = ARIA_ROLE_LIST;
|
|
128
|
+
/** Parse a target string, or null when it is not one. */
|
|
129
|
+
export function parseTarget(target) {
|
|
130
|
+
for (const by of ["testid", "text", "label"]) {
|
|
131
|
+
if (target.startsWith(`${by}=`)) {
|
|
132
|
+
const value = target.slice(by.length + 1);
|
|
133
|
+
return value ? { by, value } : null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const role = /^role=([a-z]+)(?:\[name=(?:"([^"]*)"|'([^']*)'|([^\]"']*))\])?$/i.exec(target);
|
|
137
|
+
if (!role)
|
|
138
|
+
return null;
|
|
139
|
+
const name = role[2] ?? role[3] ?? role[4];
|
|
140
|
+
return name === undefined ? { by: "role", role: role[1].toLowerCase() } : { by: "role", role: role[1].toLowerCase(), name };
|
|
141
|
+
}
|
|
142
|
+
const REQUEST_RE = /^(GET|POST|PUT|PATCH|DELETE|HEAD) (\/\S*)$/;
|
|
143
|
+
function isRegex(pattern) {
|
|
144
|
+
try {
|
|
145
|
+
new RegExp(pattern);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const target = z
|
|
153
|
+
.string()
|
|
154
|
+
.refine((t) => parseTarget(t) !== null, { message: `must be ${TARGET_HELP}` })
|
|
155
|
+
.superRefine((t, ctx) => {
|
|
156
|
+
const parsed = parseTarget(t);
|
|
157
|
+
if (parsed?.by === "role" && !ARIA_ROLES.has(parsed.role)) {
|
|
158
|
+
ctx.addIssue({
|
|
159
|
+
code: z.ZodIssueCode.custom,
|
|
160
|
+
message: `names the role "${parsed.role}", which is not an ARIA role (e.g. button, link, textbox, checkbox, tab)`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
const STEP_SCHEMAS = [
|
|
165
|
+
z.object({ action: z.literal("navigate"), target: z.string().regex(/^\//, "must be a path on the app, starting with /") }).strict(),
|
|
166
|
+
z.object({ action: z.literal("click"), target }).strict(),
|
|
167
|
+
z.object({ action: z.literal("type"), target, value: z.string(), pressEnter: z.boolean().optional(), replace: z.boolean().optional() }).strict(),
|
|
168
|
+
z.object({ action: z.literal("select"), target, value: z.string() }).strict(),
|
|
169
|
+
z.object({ action: z.literal("press"), value: z.string().min(1, "names the key to press, e.g. Enter") }).strict(),
|
|
170
|
+
z.object({ action: z.literal("expect-text"), text: z.string().min(1, "is the text that must be visible") }).strict(),
|
|
171
|
+
z.object({ action: z.literal("expect-url"), pattern: z.string().min(1).refine(isRegex, { message: "is not a valid regular expression" }) }).strict(),
|
|
172
|
+
z
|
|
173
|
+
.object({
|
|
174
|
+
action: z.literal("expect-request"),
|
|
175
|
+
request: z.string().regex(REQUEST_RE, 'must be a method and a path, e.g. "GET /api/things"'),
|
|
176
|
+
status: z.union([z.number().int().min(100).max(599), z.string().regex(/^[1-5]xx$/, 'must be a status such as 200, or a class such as "2xx"')]),
|
|
177
|
+
})
|
|
178
|
+
.strict(),
|
|
179
|
+
];
|
|
180
|
+
const stepSchema = z.discriminatedUnion("action", [...STEP_SCHEMAS]);
|
|
181
|
+
export const FLOW_ACTIONS = STEP_SCHEMAS.map((s) => s.shape.action.value);
|
|
182
|
+
const flowSchema = z
|
|
183
|
+
.object({
|
|
184
|
+
name: z.string().min(1).max(100).optional(),
|
|
185
|
+
description: z.string().max(500).optional(),
|
|
186
|
+
steps: z.array(stepSchema).min(1, "needs at least one step").max(MAX_FLOW_STEPS, `holds at most ${MAX_FLOW_STEPS} steps`),
|
|
187
|
+
})
|
|
188
|
+
.strict()
|
|
189
|
+
.superRefine((flow, ctx) => {
|
|
190
|
+
// A flow starts from a page it names, never from wherever the crawl left the browser.
|
|
191
|
+
if (flow.steps[0] && flow.steps[0].action !== "navigate") {
|
|
192
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["steps", 0, "action"], message: 'must be "navigate": a flow starts from a page it names' });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
/** `steps[2].target`, the way a person would look for it in the file. */
|
|
196
|
+
function fieldPath(p) {
|
|
197
|
+
if (p.length === 0)
|
|
198
|
+
return "(the whole file)";
|
|
199
|
+
return p.map((part, i) => (typeof part === "number" ? `[${part}]` : i === 0 ? part : `.${part}`)).join("");
|
|
200
|
+
}
|
|
201
|
+
function issueMessage(issue) {
|
|
202
|
+
if (issue.code === "unrecognized_keys")
|
|
203
|
+
return `unknown field(s) ${issue.keys.map((k) => `"${k}"`).join(", ")}`;
|
|
204
|
+
if (issue.code === "invalid_union_discriminator")
|
|
205
|
+
return `must be one of ${FLOW_ACTIONS.join(", ")}`;
|
|
206
|
+
if (issue.code === "invalid_type" && issue.received === "undefined")
|
|
207
|
+
return "is required";
|
|
208
|
+
return issue.message;
|
|
209
|
+
}
|
|
210
|
+
/** Validate one flow file's text. Every mistake names the file and the field. */
|
|
211
|
+
export function parseFlow(text, file) {
|
|
212
|
+
let json;
|
|
213
|
+
try {
|
|
214
|
+
json = JSON.parse(text);
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
return { ok: false, error: `${file}: not valid JSON (${err instanceof Error ? err.message : String(err)})` };
|
|
218
|
+
}
|
|
219
|
+
const parsed = flowSchema.safeParse(json);
|
|
220
|
+
if (!parsed.success) {
|
|
221
|
+
const issues = parsed.error.issues.slice(0, 5).map((i) => `${fieldPath(i.path)} ${issueMessage(i)}`);
|
|
222
|
+
const more = parsed.error.issues.length > 5 ? ` (and ${parsed.error.issues.length - 5} more)` : "";
|
|
223
|
+
return { ok: false, error: `${file}: ${issues.join("; ")}${more}` };
|
|
224
|
+
}
|
|
225
|
+
return { ok: true, flow: { name: parsed.data.name ?? file.replace(/\.json$/i, ""), file, steps: parsed.data.steps } };
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Read every `*.json` in a flows directory, in name order. Throws on the first
|
|
229
|
+
* file that is not a valid flow: a flow that cannot be read would otherwise
|
|
230
|
+
* pass by never running. Everything else in the directory is returned as
|
|
231
|
+
* skipped with its reason, so nothing there is left out without saying so. A
|
|
232
|
+
* symbolic link is followed when it resolves to a file inside the directory,
|
|
233
|
+
* and skipped when it resolves anywhere else.
|
|
234
|
+
*/
|
|
235
|
+
export function loadFlows(dir) {
|
|
236
|
+
const realDir = fs.realpathSync(dir);
|
|
237
|
+
const files = [];
|
|
238
|
+
const skipped = [];
|
|
239
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0))) {
|
|
240
|
+
if (e.isDirectory()) {
|
|
241
|
+
skipped.push({ file: e.name, reason: "a directory: flows are not read from subdirectories" });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (!e.name.toLowerCase().endsWith(".json")) {
|
|
245
|
+
skipped.push({ file: e.name, reason: "not a .json file" });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (e.isSymbolicLink()) {
|
|
249
|
+
let target;
|
|
250
|
+
try {
|
|
251
|
+
target = fs.realpathSync(path.join(dir, e.name));
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
skipped.push({ file: e.name, reason: "a symbolic link to nothing" });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (!target.startsWith(realDir + path.sep)) {
|
|
258
|
+
skipped.push({ file: e.name, reason: "a symbolic link to a file outside the flows directory" });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (!fs.statSync(target).isFile()) {
|
|
262
|
+
skipped.push({ file: e.name, reason: "a symbolic link to something that is not a file" });
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
else if (!e.isFile()) {
|
|
267
|
+
skipped.push({ file: e.name, reason: "not a regular file" });
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
files.push(e.name);
|
|
271
|
+
}
|
|
272
|
+
if (files.length > MAX_FLOWS)
|
|
273
|
+
throw new Error(`${dir} holds ${files.length} flows; a check replays at most ${MAX_FLOWS}`);
|
|
274
|
+
const flows = files.map((file) => {
|
|
275
|
+
const parsed = parseFlow(fs.readFileSync(path.join(dir, file), "utf8"), file);
|
|
276
|
+
if (!parsed.ok)
|
|
277
|
+
throw new Error(`flow ${parsed.error}`);
|
|
278
|
+
return parsed.flow;
|
|
279
|
+
});
|
|
280
|
+
return { flows, skipped };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Which flows directory a check uses: the one named, none for "off", and
|
|
284
|
+
* otherwise the project's own when it exists. A directory that was named and
|
|
285
|
+
* is missing is an error, never a quiet check with no flows.
|
|
286
|
+
*/
|
|
287
|
+
export function resolveFlowsDir(flag, projectDir, exists) {
|
|
288
|
+
if (flag === "off")
|
|
289
|
+
return { dir: null };
|
|
290
|
+
if (flag !== undefined)
|
|
291
|
+
return exists(flag) ? { dir: flag } : { error: `--flows: no directory at ${flag}` };
|
|
292
|
+
const own = path.join(projectDir, FLOWS_SUBDIR);
|
|
293
|
+
return { dir: exists(own) ? own : null };
|
|
294
|
+
}
|
|
295
|
+
/** A step as the report names it. */
|
|
296
|
+
export function describeStep(step) {
|
|
297
|
+
switch (step.action) {
|
|
298
|
+
case "navigate":
|
|
299
|
+
case "click":
|
|
300
|
+
case "select":
|
|
301
|
+
return `${step.action} ${step.target}`;
|
|
302
|
+
case "type":
|
|
303
|
+
return `type into ${step.target}${step.pressEnter ? " and press Enter" : ""}`;
|
|
304
|
+
case "press":
|
|
305
|
+
return `press ${step.value}`;
|
|
306
|
+
case "expect-text":
|
|
307
|
+
return `expect text ${JSON.stringify(step.text)}`;
|
|
308
|
+
case "expect-url":
|
|
309
|
+
return `expect the URL to match /${step.pattern}/`;
|
|
310
|
+
case "expect-request":
|
|
311
|
+
return `expect ${step.request} → ${step.status}`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** Whether a step acts on the page (and so opens a new window for expect-request) rather than reading it. */
|
|
315
|
+
export function isAction(step) {
|
|
316
|
+
return !step.action.startsWith("expect-");
|
|
317
|
+
}
|
|
318
|
+
/** Path, query and hash: what an expect-url pattern is tested against, so the same flow passes on a laptop and a preview deployment. */
|
|
319
|
+
export function urlMatches(pattern, url) {
|
|
320
|
+
let tail;
|
|
321
|
+
try {
|
|
322
|
+
const u = new URL(url);
|
|
323
|
+
tail = `${u.pathname}${u.search}${u.hash}`;
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
tail = url;
|
|
327
|
+
}
|
|
328
|
+
return new RegExp(pattern).test(tail);
|
|
329
|
+
}
|
|
330
|
+
/** A path pattern against a request's path: equal segment for segment, where `*` stands for any one segment. A trailing slash is ignored. */
|
|
331
|
+
export function requestPathMatches(pattern, pathname) {
|
|
332
|
+
const split = (p) => p.replace(/\/+$/, "").split("/");
|
|
333
|
+
const want = split(pattern);
|
|
334
|
+
const got = split(pathname);
|
|
335
|
+
return want.length === got.length && want.every((seg, i) => seg === "*" || seg === got[i]);
|
|
336
|
+
}
|
|
337
|
+
export function statusMatches(expected, status) {
|
|
338
|
+
return typeof expected === "number" ? status === expected : String(status)[0] === expected[0];
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Whether the requests seen since the last action include the one a step
|
|
342
|
+
* expects. The reason says what was seen instead, which is what a person
|
|
343
|
+
* fixing it needs: the right request with the wrong status, or none at all.
|
|
344
|
+
*/
|
|
345
|
+
export function matchRequest(expect, seen) {
|
|
346
|
+
const [, method, pattern] = REQUEST_RE.exec(expect.request) ?? [];
|
|
347
|
+
const same = seen.filter((r) => {
|
|
348
|
+
if (r.method.toUpperCase() !== method)
|
|
349
|
+
return false;
|
|
350
|
+
try {
|
|
351
|
+
return requestPathMatches(pattern, new URL(r.url).pathname);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
if (same.some((r) => statusMatches(expect.status, r.status)))
|
|
358
|
+
return { ok: true };
|
|
359
|
+
if (same.length > 0) {
|
|
360
|
+
const statuses = [...new Set(same.map((r) => r.status))].join(", ");
|
|
361
|
+
return { ok: false, reason: `${expect.request} answered ${statuses}, expected ${expect.status}` };
|
|
362
|
+
}
|
|
363
|
+
return { ok: false, reason: `no ${expect.request} request was sent since the last action` };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The request kinds a refused write may be excused from a step for: what
|
|
367
|
+
* navigator.sendBeacon and <a ping> send, under every engine's name for it
|
|
368
|
+
* (browsers.ts `beaconResourceType`). They are fire-and-forget by design, a
|
|
369
|
+
* page sends them on its own schedule, and no step waits for one.
|
|
370
|
+
*/
|
|
371
|
+
export const BACKGROUND_REQUEST_TYPES = new Set(["ping", ...BROWSER_ENGINES.map(beaconResourceType)]);
|
|
372
|
+
/**
|
|
373
|
+
* Split what the write policy refused during a step into what the step is
|
|
374
|
+
* charged with and background requests, which are only listed. The rule is
|
|
375
|
+
* the request's kind, never its origin: an app's API is often on another port
|
|
376
|
+
* or subdomain, and deciding by origin either hid a step's own write or
|
|
377
|
+
* blamed one on the app. So a fetch, an XHR or a form post is charged to the
|
|
378
|
+
* step wherever it goes, telemetry sent with fetch included; only a beacon or
|
|
379
|
+
* a ping is excused. A refusal with no kind recorded is charged. The app's
|
|
380
|
+
* origin is dropped from what is charged, for a shorter reason.
|
|
381
|
+
*/
|
|
382
|
+
export function splitRefusals(refusals, appOrigin) {
|
|
383
|
+
const charged = [];
|
|
384
|
+
const background = [];
|
|
385
|
+
for (const { sig, type } of refusals) {
|
|
386
|
+
if (type !== undefined && BACKGROUND_REQUEST_TYPES.has(type))
|
|
387
|
+
background.push(sig);
|
|
388
|
+
else
|
|
389
|
+
charged.push(sig.split(appOrigin).join(""));
|
|
390
|
+
}
|
|
391
|
+
return { charged, background };
|
|
392
|
+
}
|
|
393
|
+
/** The step a run stopped at, as the evidence of its issue or the reason the check could not run. */
|
|
394
|
+
export function flowStepEvidence(run) {
|
|
395
|
+
if (run.outcome.status === "passed")
|
|
396
|
+
return "";
|
|
397
|
+
return `flow "${run.name}" (${run.file}) step ${run.outcome.step} of ${run.steps}, ${run.outcome.did}: ${run.outcome.reason}`;
|
|
398
|
+
}
|