scenescout 3.8.0 → 3.10.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.
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.
26
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.
34
- */
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)
@@ -402,13 +478,89 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
402
478
  }
403
479
  return { images, total };
404
480
  })()`;
481
+ /**
482
+ * An element's coverage key inside a frame: the frame's origin (another site)
483
+ * or path (the app's own) before the element's own key, so a "Submit" in an
484
+ * embed is not the page's "Submit". Elements of the page itself keep their key
485
+ * unchanged, so pages without frames keep the coverage they had.
486
+ */
487
+ export function frameElementKey(baseKey, frame) {
488
+ if (!frame)
489
+ return baseKey;
490
+ let where = frame.origin || frame.url;
491
+ if (!frame.foreign) {
492
+ try {
493
+ const u = new URL(frame.url);
494
+ // A frame with no web address (srcdoc, about:blank) is told apart by its title.
495
+ where = u.protocol === "http:" || u.protocol === "https:" ? u.pathname : `${frame.url}#${frame.title}`;
496
+ }
497
+ catch {
498
+ where = `${frame.url}#${frame.title}`;
499
+ }
500
+ }
501
+ return `frame:${where}|${baseKey}`;
502
+ }
503
+ /** The longest name kept for a control in another site's frame: a chat that renders messages as buttons would otherwise print them whole. */
504
+ export const MAX_FOREIGN_NAME = 40;
505
+ /** A control's name from another site's frame, cut to MAX_FOREIGN_NAME. */
506
+ export function capForeignName(name) {
507
+ return name.length > MAX_FOREIGN_NAME ? `${name.slice(0, MAX_FOREIGN_NAME)}…` : name;
508
+ }
509
+ /** A link's address from another site's frame without its query and fragment, where tokens and addresses travel. */
510
+ export function stripForeignHref(href) {
511
+ try {
512
+ const u = new URL(href);
513
+ return u.origin + u.pathname;
514
+ }
515
+ catch {
516
+ return href.split(/[?#]/)[0];
517
+ }
518
+ }
519
+ /**
520
+ * A rect read inside a frame, in the page's document coordinates: the frame
521
+ * element's box on screen, less the frame's own scroll, plus the page's.
522
+ */
523
+ export function frameToPageRect(rect, box, frameScroll, pageScroll) {
524
+ return { ...rect, x: rect.x + box.x + pageScroll.x - frameScroll.x, y: rect.y + box.y + pageScroll.y - frameScroll.y };
525
+ }
526
+ /** How a snapshot line names the frame an element is in. */
527
+ export function frameLabel(frame) {
528
+ let where = frame.url;
529
+ if (!frame.foreign) {
530
+ try {
531
+ const u = new URL(frame.url);
532
+ where = u.pathname + u.search;
533
+ }
534
+ catch {
535
+ /* shown as it is */
536
+ }
537
+ }
538
+ else if (frame.origin)
539
+ where = frame.origin;
540
+ return `${frame.foreign ? "cross-origin" : "same-origin"} frame ${where.slice(0, 80)}${frame.title ? ` "${frame.title.slice(0, 40)}"` : ""}`;
541
+ }
542
+ /**
543
+ * Whether an element's name, read from another site's frame, is masked. A
544
+ * name taken from a container's text — a select's options, a textarea's
545
+ * contents, a tagged <div> — can carry other people's data (a support chat,
546
+ * a customer record) into the snapshot, the transcript and the report. The
547
+ * label of a link, a button or a field is the interface itself and is kept:
548
+ * the agent needs it to act.
549
+ */
550
+ export function masksForeignName(tag, role) {
551
+ if (tag === "a" || tag === "button" || tag === "input")
552
+ return false;
553
+ return !/^(button|link|tab|menuitem|checkbox|switch|radio)$/.test(role);
554
+ }
555
+ /** The name shown for a masked element. */
556
+ export const MASKED_NAME = "(content masked: another site's frame)";
405
557
  /** A frame smaller than this in both directions is plumbing (a tracking pixel, a messaging bridge), not something a user sees. */
406
558
  const VISIBLE_FRAME_PX = 2;
407
559
  /**
408
- * The snapshot's account of the page's frames. Nothing inside a frame is
409
- * collected or can be acted on yet, and a page that shows its form in an
410
- * embed used to look like a page with no form at all: say what is there,
411
- * where it comes from, and that it was not looked inside. `nested` counts
560
+ * The snapshot's account of the page's frames: what is embedded, where it
561
+ * comes from, and whether its controls were read (`read`, by frame URL) —
562
+ * a page that shows its form in an embed used to look like a page with no
563
+ * form at all. Without `read`, nothing was looked inside. `nested` counts
412
564
  * frames that are not read: nested inside others, or past the first 30; `writesRefused` is false only in
413
565
  * destructive mode, where a foreign frame's writes do go out.
414
566
  */
@@ -436,12 +588,22 @@ export function frameLines(appUrl, frames, opts = {}) {
436
588
  /* about:blank, srcdoc: shown as they are */
437
589
  }
438
590
  const label = f.title ? ` "${f.title.slice(0, 60)}"` : "";
591
+ let frameOrigin = "";
592
+ try {
593
+ frameOrigin = new URL(f.url).origin;
594
+ }
595
+ catch {
596
+ /* no origin */
597
+ }
439
598
  const writes = f.foreign
440
599
  ? opts.writesRefused === false
441
600
  ? " — its writes go out (destructive mode)"
442
- : " — writes it sends outside the app are refused"
601
+ : opts.trustedWrites?.has(frameOrigin)
602
+ ? " — trusted embed: its writes go out (safe-write)"
603
+ : " — writes it sends outside the app are refused"
443
604
  : "";
444
- return ` ${f.foreign ? "cross-origin" : "same-origin"} ${where.slice(0, 120)}${label} ${f.width}×${f.height}${writes}`;
605
+ const read = opts.read ? (opts.read.has(f.url) ? " — controls listed above" : " — not read") : "";
606
+ return ` ${f.foreign ? "cross-origin" : "same-origin"} ${where.slice(0, 120)}${label} ${f.width}×${f.height}${read}${writes}`;
445
607
  });
446
608
  if (visible.length > 10)
447
609
  lines.push(` … +${visible.length - 10} more`);
@@ -449,7 +611,14 @@ export function frameLines(appUrl, frames, opts = {}) {
449
611
  lines.push(` (+${hidden} hidden frame${hidden === 1 ? "" : "s"})`);
450
612
  if (nested > 0)
451
613
  lines.push(` (+${nested} more frame${nested === 1 ? "" : "s"}, nested inside those or past the first 30, not read)`);
452
- return [`FRAMES not explored — their controls are not listed above and cannot be acted on:`, ...lines];
614
+ const header = opts.read && opts.read.size > 0
615
+ ? "FRAMES — the controls of each frame read are listed above, marked ⟨in … frame⟩, and can be acted on by ref" +
616
+ (visible.some((f) => f.foreign)
617
+ ? "; in another site's frame, content is masked and hostile input, repeated-click probes and uploads are refused"
618
+ : "") +
619
+ ":"
620
+ : "FRAMES not explored — their controls are not listed above and cannot be acted on:";
621
+ return [header, ...lines];
453
622
  }
454
623
  /** Whether any frame on the page is one a user can see. */
455
624
  export function hasVisibleFrame(frames) {
@@ -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,290 @@
1
+ /**
2
+ * Forms never submitted empty.
3
+ *
4
+ * The commonest defect behind a form is a submit that silently does nothing
5
+ * when its required fields are blank: no request, no message. The engine
6
+ * already notes a submit-style click that fires no request, but only when
7
+ * somebody tries the empty submit, and lanes that fill a form in and submit
8
+ * it rarely do. So scout_coverage lists every form a session saw that no
9
+ * session has yet submitted empty.
10
+ *
11
+ * THE DEFINITION. A form is a `<form>` element. Its controls are the ones the
12
+ * browser associates with it (`form.elements`), so a submit button outside
13
+ * the element linked back by `form="id"` is one of them. It is tracked when it
14
+ * has a native submit control (a `<button>` with no type or type="submit", an
15
+ * `<input type="submit|image">`) and at least one TEXT-ENTRY field.
16
+ *
17
+ * IDENTITY (formIdentity), per route, first match wins:
18
+ * 1. the form's own `id` attribute → `form:#signup`
19
+ * 2. its `name` attribute → `form:name=pay`
20
+ * 3. its `action` attribute with its method → `form:POST /things`
21
+ * 4. only when it has none of those, the coverage key the snapshot gives its
22
+ * first submit control (`tid:thing-save`, `button:save`).
23
+ * The form's own attributes come first because a submit control's key is the
24
+ * weakest name it has: an untagged "Save" in two tab panels is the same key
25
+ * for two forms, and a label that changes ("Pay $12", "Saving…") is a new key
26
+ * for the same one. Attributes are read with getAttribute, never `form.id` or
27
+ * `form.name`, which a field named "id" or "name" shadows.
28
+ *
29
+ * Only the inventory (a snapshot, a crawl, a plan's capture) puts a form on
30
+ * the list. A submit only marks a listed form; one that cannot be matched to
31
+ * a listed form is logged (FORMS_SUBMIT_UNMATCHED), never guessed.
32
+ *
33
+ * A text-entry field is a `<textarea>`, or an `<input>` whose type takes typed
34
+ * text (TEXT_ENTRY_TYPES), that is visible, not disabled (itself or by a
35
+ * disabled fieldset) and not read-only. Checkboxes, radios, selects, file,
36
+ * hidden, search, range and colour inputs are not: none of them can be "left
37
+ * blank" by typing, and a search box's empty submit is a filter, not the
38
+ * defect this looks for.
39
+ *
40
+ * A form counts as SUBMITTED EMPTY when a submit happened while every one of
41
+ * its text-entry fields held the empty string (whitespace is content: that is
42
+ * a separate boundary probe). A submit is a click on one of its native submit
43
+ * controls, or Enter where the browser's implicit submission applies: in one
44
+ * of its text-like inputs (IMPLICIT_SUBMIT_TYPES) while its default button is
45
+ * enabled, or on a focused submit control. It counts whether the click or key
46
+ * came from scout_click, scout_type's pressEnter, scout_press or a plan step.
47
+ * A form with no text-entry field is never listed, and neither is one whose
48
+ * every submit control is disabled while its fields are blank: the page
49
+ * already refuses the empty submit, and nothing could clear the entry.
50
+ *
51
+ * WHY ONLY `<form>`. A "form-like group" of loose fields and buttons had to be
52
+ * guessed from layout and button labels. The guess listed page chrome (a
53
+ * header search box beside "Sign in", under an app root that held every
54
+ * row's "Add" button) and walked the whole document once per button. A real
55
+ * form is the one grouping the browser itself submits, so it is exact and
56
+ * cheap (`document.forms`).
57
+ *
58
+ * NOT CAUGHT, by design of the above:
59
+ * - fields and a save button with no `<form>` around them (a JS-driven panel);
60
+ * - forms whose only submit control is a `role="button"` element, or a
61
+ * `<button type="button">` wired up in script;
62
+ * - forms that submit from script on change (`form.submit()` in a handler);
63
+ * - Ctrl+Enter or Cmd+Enter in a textarea, which some apps treat as send.
64
+ * And kept apart imperfectly, by design of IDENTITY:
65
+ * - forms with no id or name that share an action and method are one entry,
66
+ * so an empty submit of one clears them all;
67
+ * - forms with no id, name or action whose first submit controls are the
68
+ * same untagged button ("Save") are one entry too;
69
+ * - on a page with more controls than a snapshot lists (150), a form with
70
+ * none of those attributes whose submit control is past the cap is not
71
+ * tracked at all.
72
+ *
73
+ * The page reports facts; the rule deciding what they mean lives here as pure
74
+ * functions, so it is table-tested (memory-test) without a browser.
75
+ */
76
+ import { ACCESSIBLE_NAME_SRC, VISIBLE_SRC, XPATH_OF_SRC } from "./collector.js";
77
+ import { normalizePath } from "./fingerprint.js";
78
+ /**
79
+ * Action-log entries this bookkeeping writes. They record what the engine
80
+ * could not do, not a step anyone took on the page: pace.ts does not count
81
+ * them as actions and a finding's repro trace leaves them out.
82
+ */
83
+ export const FORMS_READ_FAILED = "forms:read-failed";
84
+ export const FORMS_SUBMIT_UNMATCHED = "forms:submit-unmatched";
85
+ /** Whether an action-log entry is this bookkeeping rather than a step. */
86
+ export function isFormBookkeeping(action) {
87
+ return action === FORMS_READ_FAILED || action === FORMS_SUBMIT_UNMATCHED;
88
+ }
89
+ /** The form's identity from its own attributes, or null when it has none (see IDENTITY above). */
90
+ export function formIdentity(attrs) {
91
+ const id = attrs.id.trim();
92
+ if (id)
93
+ return `form:#${id.slice(0, 80)}`;
94
+ const name = attrs.name.trim();
95
+ if (name)
96
+ return `form:name=${name.slice(0, 80)}`;
97
+ // Normalized as a route is: a record id or a per-load token in the action
98
+ // would otherwise name a new form on every record or every load.
99
+ const action = attrs.action.trim();
100
+ if (action)
101
+ return `form:${(attrs.method.trim() || "get").toUpperCase()} ${normalizePath(action).slice(0, 120)}`;
102
+ return null;
103
+ }
104
+ /**
105
+ * Whether a snapshot element is the submit control the probe found. An xpath
106
+ * alone is not enough: a sibling added or removed since the snapshot makes the
107
+ * same path name another button (a "Cancel" where "Send" was). The testid and
108
+ * the accessible name, computed by the collector's own rule, must agree too.
109
+ */
110
+ export function sameControl(el, probe) {
111
+ return (el.testid ?? null) === (probe.submitTestid ?? null) && el.name === probe.submitName;
112
+ }
113
+ /** Input types that take typed text. `search` is left out on purpose (see above). */
114
+ export const TEXT_ENTRY_TYPES = new Set([
115
+ "text",
116
+ "email",
117
+ "number",
118
+ "tel",
119
+ "url",
120
+ "password",
121
+ "date",
122
+ "datetime-local",
123
+ "month",
124
+ "week",
125
+ "time",
126
+ ]);
127
+ /**
128
+ * Input types in which Enter submits the form (the HTML standard's implicit
129
+ * submission). Not checkboxes, radios or buttons: Enter does nothing there.
130
+ */
131
+ export const IMPLICIT_SUBMIT_TYPES = new Set([...TEXT_ENTRY_TYPES, "search"]);
132
+ /** Whether a field takes typed text and a user could type into it right now. */
133
+ export function isTextEntry(f) {
134
+ if (!f.visible || f.disabled || f.readOnly)
135
+ return false;
136
+ if (f.tag === "textarea")
137
+ return true;
138
+ return f.tag === "input" && TEXT_ENTRY_TYPES.has(f.type || "text");
139
+ }
140
+ /** Whether a form is tracked at all: it must hold something to leave blank. */
141
+ export function tracksForm(fields) {
142
+ return fields.some(isTextEntry);
143
+ }
144
+ /** Whether every text-entry field is empty — and there is at least one. */
145
+ export function allTextEmpty(fields) {
146
+ const text = fields.filter(isTextEntry);
147
+ return text.length > 0 && text.every((f) => !f.filled);
148
+ }
149
+ /**
150
+ * What the inventory makes of a form: not tracked, tracked and owed an empty
151
+ * submit, or GUARDED — every submit control disabled while the fields are
152
+ * blank, so the page itself refuses the empty submit and nobody could clear
153
+ * the entry by trying it.
154
+ */
155
+ export function formStatus(form) {
156
+ if (!tracksForm(form.fields))
157
+ return "untracked";
158
+ return form.submitDisabled && allTextEmpty(form.fields) ? "guarded" : "open";
159
+ }
160
+ /**
161
+ * Whether the action submitted the form. A click submits when it lands on a
162
+ * native submit control. Enter submits from a focused submit control, or from
163
+ * a text-like input while the form's default button is enabled.
164
+ */
165
+ export function submits(kind, probe) {
166
+ if (probe.selfIsSubmit)
167
+ return true;
168
+ if (kind !== "enter" || probe.defaultDisabled)
169
+ return false;
170
+ return probe.self.tag === "input" && IMPLICIT_SUBMIT_TYPES.has(probe.self.type || "text");
171
+ }
172
+ /** Whether this action was an empty submit of a tracked form. */
173
+ export function isEmptySubmit(kind, probe) {
174
+ return probe !== null && submits(kind, probe) && allTextEmpty(probe.fields);
175
+ }
176
+ /**
177
+ * Whether a failed page read is the page going away under it (a navigation
178
+ * or a closed tab), which is expected after a submit and says nothing. Any
179
+ * other failure is worth a line in the action log.
180
+ */
181
+ export function isNavigationTeardown(message) {
182
+ return /execution context was destroyed|target (page, context or browser )?(has been )?closed|frame (was|has been) detached|navigat/i.test(message);
183
+ }
184
+ /** Page-side helpers shared by the inventory and the probe. Statements, not an expression. */
185
+ const FORM_HELPERS_SRC = `
186
+ const visible = ${VISIBLE_SRC};
187
+ const xpathOf = ${XPATH_OF_SRC};
188
+ const facts = (f) => ({
189
+ tag: f.tagName.toLowerCase(),
190
+ type: f.tagName.toLowerCase() === "input" ? String(f.type || "text").toLowerCase() : "",
191
+ disabled: f.matches(":disabled"),
192
+ readOnly: f.readOnly === true,
193
+ visible: visible(f),
194
+ filled: typeof f.value === "string" && f.value !== "",
195
+ });
196
+ // The type PROPERTY: a button with no type, or an unknown one, reports "submit".
197
+ const isSubmit = (el) => {
198
+ const tag = el.tagName.toLowerCase();
199
+ return (tag === "button" && el.type === "submit") || (tag === "input" && (el.type === "submit" || el.type === "image"));
200
+ };
201
+ const submitsOf = (form) => Array.from(form.elements).filter((c) => isSubmit(c) && visible(c));
202
+ // Each DISTINCT set of field facts once. The rules ask whether any field is
203
+ // text entry and whether every one is blank, which repeats do not change,
204
+ // and a form of 2000 identical rows would otherwise ship 2000 objects back.
205
+ const fieldFactsOf = (form) => {
206
+ const seen = new Map();
207
+ for (const c of Array.from(form.elements)) {
208
+ if (!/^(input|textarea|select)$/i.test(c.tagName) || c.type === "hidden") continue;
209
+ const f = facts(c);
210
+ const id = f.tag + "|" + f.type + "|" + f.disabled + "|" + f.readOnly + "|" + f.visible + "|" + f.filled;
211
+ if (!seen.has(id)) seen.set(id, f);
212
+ }
213
+ return Array.from(seen.values());
214
+ };
215
+ // Resolved against the base URL, so a relative and an absolute action agree;
216
+ // one that is not a URL at all is kept as written.
217
+ const resolvedAction = (form) => {
218
+ const raw = (form.getAttribute("action") || "").trim();
219
+ if (!raw) return "";
220
+ try { return new URL(raw, document.baseURI).href; } catch (e) { return raw; }
221
+ };
222
+ // getAttribute, not form.id / form.name: a field named "id" or "name" shadows those.
223
+ const attrsOf = (form) => ({
224
+ id: form.getAttribute("id") || "",
225
+ name: form.getAttribute("name") || "",
226
+ action: resolvedAction(form),
227
+ method: form.getAttribute("method") || "",
228
+ });
229
+ `;
230
+ /**
231
+ * Page-side: every form on the page with a visible native submit control, as
232
+ * the xpath of its first one and the facts of its fields. One pass over
233
+ * `document.forms`; which of them are tracked is decided in Node (formStatus).
234
+ */
235
+ export const FORMS_INVENTORY_SCRIPT = `(() => {
236
+ ${FORM_HELPERS_SRC}
237
+ const out = [];
238
+ for (const form of Array.from(document.forms)) {
239
+ const submits = submitsOf(form);
240
+ if (submits.length === 0) continue;
241
+ out.push({
242
+ attrs: attrsOf(form),
243
+ submit: xpathOf(submits[0]),
244
+ fields: fieldFactsOf(form),
245
+ submitDisabled: submits.every((s) => s.matches(":disabled")),
246
+ });
247
+ }
248
+ return out;
249
+ })()`;
250
+ /**
251
+ * Page-side body of the probe run on the element an action is about to
252
+ * submit through: `(node) => FormProbe | null`, null when it belongs to no
253
+ * form. Shipped as source rather than a transpiled function, for the reason
254
+ * the collector is a string: a loader's helpers do not exist in the page.
255
+ */
256
+ export const FORM_PROBE_BODY = `
257
+ if (!node || node.nodeType !== 1 || !node.form) return null;
258
+ ${FORM_HELPERS_SRC}
259
+ const form = node.form;
260
+ const submits = submitsOf(form);
261
+ if (submits.length === 0) return null;
262
+ const accessibleName = ${ACCESSIBLE_NAME_SRC};
263
+ return {
264
+ attrs: attrsOf(form),
265
+ submit: xpathOf(submits[0]),
266
+ submitTestid: submits[0].getAttribute("data-testid"),
267
+ submitName: accessibleName(submits[0]),
268
+ fields: fieldFactsOf(form),
269
+ self: facts(node),
270
+ selfIsSubmit: isSubmit(node),
271
+ defaultDisabled: submits[0].matches(":disabled"),
272
+ };
273
+ `;
274
+ /** The probe as an expression over a node expression, for a page-level evaluate. */
275
+ export function formProbeExpression(nodeExpression) {
276
+ return `((node) => {${FORM_PROBE_BODY}})(${nodeExpression})`;
277
+ }
278
+ /**
279
+ * The scout_coverage lines for forms no session has submitted empty this
280
+ * run. Empty when there is nothing to say.
281
+ */
282
+ export function formatNeverSubmittedEmpty(forms) {
283
+ if (forms.length === 0)
284
+ return [];
285
+ return [
286
+ "Forms never submitted empty this run (submit each once with every text field blank: a submit that silently does nothing — no request, no message — hides there):",
287
+ ...forms.slice(0, 15).map((f) => ` ${f.route} ${f.key}`),
288
+ ...(forms.length > 15 ? [` … +${forms.length - 15} more`] : []),
289
+ ];
290
+ }