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.
@@ -19,21 +19,12 @@ export const VISIBLE_SRC = `(el) => {
19
19
  return style.visibility !== "hidden" && style.display !== "none";
20
20
  }`;
21
21
  /**
22
- * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
23
- * than the ARIA set: a hand-rolled role-less modal must still count as "an
24
- * overlay is up", or the scroll-lock oracle files a false leaked-lock finding
25
- * against every healthy modal that locks the page behind it.
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 COLLECT_INTERACTABLES_SCRIPT = `(() => {
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
- const visible = ${VISIBLE_SRC};
52
- const accessibleName = (el) => {
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
- /** Whether an element with this role and no accessible name is an unnamed control, as opposed to a live region with nothing in it yet. */
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)
@@ -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
- const chromeIssues = [
695
- ...contrastFailures(chromeRecords),
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
- return { report, score, signatures };
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
+ }