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.
- package/CHANGELOG.md +23 -0
- package/README.md +52 -3
- package/dist/browsers.js +12 -0
- package/dist/check-run.js +76 -0
- package/dist/cli.js +71 -6
- package/dist/engine/browser.js +514 -45
- package/dist/engine/check.js +487 -0
- package/dist/engine/collector.js +194 -25
- package/dist/engine/design.js +24 -8
- package/dist/engine/forms.js +290 -0
- package/dist/engine/lane.js +179 -2
- package/dist/engine/memory.js +70 -3
- package/dist/engine/oracles.js +101 -4
- package/dist/engine/pace.js +14 -1
- package/dist/engine/policy.js +118 -0
- package/dist/engine/report.js +54 -5
- package/dist/mcp-server.js +49 -9
- package/package.json +6 -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
|
-
*
|
|
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
|
|
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
|
-
|
|
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)
|
|
@@ -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
|
|
409
|
-
*
|
|
410
|
-
* embed used to look like a page with no
|
|
411
|
-
*
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
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,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
|
+
}
|