scenescout 3.17.0 → 3.18.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 +17 -0
- package/README.md +6 -3
- package/dist/check-run.js +14 -7
- package/dist/engine/browser.js +108 -32
- package/dist/engine/check.js +92 -7
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +73 -3
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/first-run.js +7 -1
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +41 -0
- package/dist/playbook.js +1 -1
- package/package.json +1 -1
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/collector.js
CHANGED
|
@@ -39,6 +39,25 @@ export const XPATH_OF_SRC = `(el) => {
|
|
|
39
39
|
}
|
|
40
40
|
return "/html/" + parts.join("/");
|
|
41
41
|
}`;
|
|
42
|
+
/**
|
|
43
|
+
* What one descendant of a control says toward the control's name, as
|
|
44
|
+
* page-side source, in the order the accessible-name computation reads it
|
|
45
|
+
* when a name comes from content: its aria-label; for an <img>, its alt text,
|
|
46
|
+
* and when that is empty or missing, its title (an avatar with alt="" and a
|
|
47
|
+
* title names the link around it); for an <svg>, its <title>. Anything else
|
|
48
|
+
* says nothing. Kept apart from NAME_FACTS_SRC so the order can be
|
|
49
|
+
* table-tested without a browser (contentSays).
|
|
50
|
+
*/
|
|
51
|
+
export const CONTENT_SAYS_SRC = `(d) => {
|
|
52
|
+
if (d.ariaLabel && d.ariaLabel.trim()) return d.ariaLabel;
|
|
53
|
+
if (d.tag === "img") return d.alt && d.alt.trim() ? d.alt : d.title;
|
|
54
|
+
if (d.tag === "svg") return d.svgTitle;
|
|
55
|
+
return "";
|
|
56
|
+
}`;
|
|
57
|
+
/** CONTENT_SAYS_SRC run outside a page, from the same source the page runs. */
|
|
58
|
+
export function contentSays(facts) {
|
|
59
|
+
return new Function(`return (${CONTENT_SAYS_SRC});`)()(facts);
|
|
60
|
+
}
|
|
42
61
|
/**
|
|
43
62
|
* What an element's accessible name is read from, as page-side source: every
|
|
44
63
|
* candidate source, read from the DOM and nothing decided. PICK_NAME_SRC
|
|
@@ -48,31 +67,45 @@ export const XPATH_OF_SRC = `(el) => {
|
|
|
48
67
|
* holds the control, and a select's options or a textarea's contents are not
|
|
49
68
|
* its label.
|
|
50
69
|
*
|
|
51
|
-
* `content` is what the element's
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* anything inside an aria-hidden part of the element.
|
|
70
|
+
* `content` is what the element's content says that its text cannot hold:
|
|
71
|
+
* the first descendant, in document order, that CONTENT_SAYS_SRC finds a word
|
|
72
|
+
* in (an aria-label, an image's alt or title, an svg's <title>), skipping
|
|
73
|
+
* anything inside an aria-hidden part of the element. `content2` is what the
|
|
74
|
+
* same content said under name rule 2 (memory.ts NAME_RULE), which read only
|
|
75
|
+
* an <img>'s alt and an <svg>'s or role="img"'s aria-label or <title>: it is
|
|
76
|
+
* read so a control renamed since keeps its coverage.
|
|
55
77
|
*/
|
|
56
78
|
export const NAME_FACTS_SRC = `(el) => {
|
|
57
79
|
const tag = el.tagName.toLowerCase();
|
|
58
80
|
const role = el.getAttribute("role") || "";
|
|
59
81
|
const inputType = tag === "input" ? el.type : "";
|
|
60
|
-
const byId = (id) => { const n = document.getElementById(id); return n ? n.textContent || "" : ""; };
|
|
82
|
+
const byId = (id) => { const n = (el.ownerDocument || document).getElementById(id); return n ? n.textContent || "" : ""; };
|
|
61
83
|
const labels = [];
|
|
62
84
|
for (const label of Array.from(el.labels || [])) {
|
|
63
85
|
const own = label.contains(el) ? el.textContent || "" : "";
|
|
64
86
|
labels.push((label.textContent || "").replace(own, ""));
|
|
65
87
|
}
|
|
88
|
+
const says = ${CONTENT_SAYS_SRC};
|
|
66
89
|
let content = "";
|
|
67
|
-
|
|
90
|
+
let content2 = "";
|
|
91
|
+
for (const d of el.querySelectorAll("img, svg, [role='img'], [aria-label]")) {
|
|
68
92
|
const hidden = d.closest('[aria-hidden="true"]');
|
|
69
93
|
if (hidden && el.contains(hidden)) continue;
|
|
70
|
-
const
|
|
71
|
-
const said = (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
const svgTitle = d.tagName.toLowerCase() === "svg" ? d.querySelector(":scope > title") : null;
|
|
95
|
+
const said = says({
|
|
96
|
+
tag: d.tagName.toLowerCase(),
|
|
97
|
+
ariaLabel: d.getAttribute("aria-label"),
|
|
98
|
+
alt: d.getAttribute("alt"),
|
|
99
|
+
title: d.getAttribute("title") || "",
|
|
100
|
+
svgTitle: svgTitle ? svgTitle.textContent || "" : "",
|
|
101
|
+
});
|
|
102
|
+
if (!content && said.trim()) content = said;
|
|
103
|
+
// Name rule 2, frozen: what this content said before a descendant's aria-label and an image's title counted.
|
|
104
|
+
if (!content2 && d.matches("img, svg, [role='img']")) {
|
|
105
|
+
const said2 = (d.tagName === "IMG" ? d.getAttribute("alt") : d.getAttribute("aria-label") || (svgTitle ? svgTitle.textContent : "")) || "";
|
|
106
|
+
if (said2.trim()) content2 = said2;
|
|
75
107
|
}
|
|
108
|
+
if (content && content2) break;
|
|
76
109
|
}
|
|
77
110
|
const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
|
|
78
111
|
(el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
|
|
@@ -91,6 +124,7 @@ export const NAME_FACTS_SRC = `(el) => {
|
|
|
91
124
|
live,
|
|
92
125
|
text: el.innerText || el.textContent || "",
|
|
93
126
|
content,
|
|
127
|
+
content2,
|
|
94
128
|
};
|
|
95
129
|
}`;
|
|
96
130
|
/**
|
|
@@ -105,15 +139,18 @@ export const NAME_FACTS_SRC = `(el) => {
|
|
|
105
139
|
* fallback stays.
|
|
106
140
|
*
|
|
107
141
|
* - A select is never named by its options: their text is its value, not its name.
|
|
108
|
-
* - Other elements are named by their text; with none, by their
|
|
142
|
+
* - Other elements are named by their text; with none, by their content
|
|
109
143
|
* (a link holding only a logo with alt text, a button holding only an svg
|
|
110
|
-
* with a <title
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
144
|
+
* with a <title>, a link around an icon element with an aria-label or an
|
|
145
|
+
* avatar with a title); and with neither, by title (an icon-only button
|
|
146
|
+
* with a tooltip). Text and content are not joined: a control with both is
|
|
147
|
+
* named by its text, as it was before content counted.
|
|
148
|
+
* - A name taken from content carries `priors`, the name each earlier rule
|
|
149
|
+
* gave (memory.ts NAME_RULE), rule 1 first: rule 1 read no content (the
|
|
150
|
+
* title, or nothing), rule 2 read only image content. The name is half of
|
|
151
|
+
* the coverage key, so the key under each earlier rule is kept beside the
|
|
152
|
+
* new one and coverage recorded under it carries over (memory.ts keyAliases,
|
|
153
|
+
* ruleKeyAliases).
|
|
117
154
|
* - A live region (status, alert, log, timer, an aria-live region, <output>)
|
|
118
155
|
* is named by the text it announces, which is what matters after an action.
|
|
119
156
|
* - A whitespace-only aria-label ends the name with nothing, as it always has
|
|
@@ -145,7 +182,11 @@ export const PICK_NAME_SRC = `(f) => {
|
|
|
145
182
|
return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
|
|
146
183
|
}
|
|
147
184
|
if (clean(f.text)) return named(clean(f.text));
|
|
148
|
-
if (clean(f.content))
|
|
185
|
+
if (clean(f.content)) {
|
|
186
|
+
// The name under rule 1 (no content) and under rule 2 (content2), so coverage keys can be carried across.
|
|
187
|
+
const title = clean(f.title).slice(0, 80);
|
|
188
|
+
return { ...named(clean(f.content)), priors: [title, clean(f.content2) ? clean(f.content2).slice(0, 80) : title] };
|
|
189
|
+
}
|
|
149
190
|
return named(clean(f.title));
|
|
150
191
|
}`;
|
|
151
192
|
/**
|
|
@@ -656,7 +697,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
|
|
|
656
697
|
name: named.name,
|
|
657
698
|
...policyText(el),
|
|
658
699
|
nameFrom: named.from,
|
|
659
|
-
...(named.
|
|
700
|
+
...(named.priors ? { priorNames: named.priors } : {}),
|
|
660
701
|
testid: el.getAttribute("data-testid"),
|
|
661
702
|
interactive,
|
|
662
703
|
affords: affords && (affords.tabStop || affords.clickHandler || affords.pointer) ? affords : null,
|
package/dist/engine/crawl.js
CHANGED
|
@@ -47,3 +47,40 @@ export function crawlLine(o, counts, flags) {
|
|
|
47
47
|
(counts.unnamed ? ` · ${counts.unnamed} unnamed` : "") +
|
|
48
48
|
(allFlags.length ? ` · ${allFlags.join(" ")}` : ""));
|
|
49
49
|
}
|
|
50
|
+
/** The media types a browser renders as a page: anything else a route answers with is a file, a feed or data. */
|
|
51
|
+
export const PAGE_MEDIA_TYPES = ["text/html", "application/xhtml+xml"];
|
|
52
|
+
/**
|
|
53
|
+
* A Content-Type header's media type, lowercased and without its parameters
|
|
54
|
+
* ("text/HTML; charset=utf-8" → "text/html"); undefined when there is none.
|
|
55
|
+
*/
|
|
56
|
+
export function mediaTypeOf(header) {
|
|
57
|
+
const type = (header ?? "").split(";")[0].trim().toLowerCase();
|
|
58
|
+
return type === "" ? undefined : type;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Whether a route's document answered as something other than a page: an RSS
|
|
62
|
+
* or Atom feed, XML, JSON, plain text, a PDF, an image. Decided from the
|
|
63
|
+
* response's content type, not the path, because a feed at `/feed`, a licence
|
|
64
|
+
* file or a raw-file view has no extension to give it away, and a path ending
|
|
65
|
+
* `.xml` can be served as HTML. Such a route has no controls by nature, so the
|
|
66
|
+
* page rules (a dead end among them) do not apply to it, and it is not a page
|
|
67
|
+
* for the route count. Only a response that succeeded counts: the body of a
|
|
68
|
+
* 4xx or 5xx describes the error, not the route, and the error is reported as
|
|
69
|
+
* the route's own. A response with no content type is read as a page, the
|
|
70
|
+
* behaviour before the type was consulted.
|
|
71
|
+
*/
|
|
72
|
+
export function isNonPageResource(r) {
|
|
73
|
+
if (r.status === null || r.status >= 400)
|
|
74
|
+
return false;
|
|
75
|
+
return isFileMediaType(r.contentType);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether a response's content type names something other than a page, whatever
|
|
79
|
+
* its status. A browser may take such a response as a download and fail the
|
|
80
|
+
* navigation, so this is what says the route answered (with its status) rather
|
|
81
|
+
* than failed to load.
|
|
82
|
+
*/
|
|
83
|
+
export function isFileMediaType(contentType) {
|
|
84
|
+
const type = mediaTypeOf(contentType);
|
|
85
|
+
return type !== undefined && !PAGE_MEDIA_TYPES.includes(type);
|
|
86
|
+
}
|
package/dist/engine/design.js
CHANGED
|
@@ -22,6 +22,32 @@
|
|
|
22
22
|
* controls, visible keyboard focus.
|
|
23
23
|
*/
|
|
24
24
|
import { missingName, NAME_SRC, placeholderOnly } from "./collector.js";
|
|
25
|
+
/**
|
|
26
|
+
* The control a Tab press focused, as the focus-indicator rule measures it. It runs
|
|
27
|
+
* page-side (probes.ts embeds its source), so it is plain JavaScript once compiled.
|
|
28
|
+
*
|
|
29
|
+
* A Tab that lands on an iframe or frame moves focus into the frame's document: the
|
|
30
|
+
* frame element is not the control the user sees focused, and its own outline staying
|
|
31
|
+
* the same says nothing. So the walk follows focus into a same-origin frame and returns
|
|
32
|
+
* the element focused there. It returns "frame" when focus is inside a frame it cannot
|
|
33
|
+
* follow (another site's, whose document is not readable) or a frame whose document has
|
|
34
|
+
* nothing focused: that press is skipped, not sampled. It returns null when nothing in
|
|
35
|
+
* the page is focused, which ends the sampling.
|
|
36
|
+
*/
|
|
37
|
+
export function focusedControl(doc) {
|
|
38
|
+
let d = doc;
|
|
39
|
+
let el = d.activeElement;
|
|
40
|
+
while (el && /^i?frame$/i.test(el.tagName)) {
|
|
41
|
+
const inner = el.contentDocument ?? null;
|
|
42
|
+
if (!inner)
|
|
43
|
+
return "frame";
|
|
44
|
+
d = inner;
|
|
45
|
+
el = inner.activeElement;
|
|
46
|
+
}
|
|
47
|
+
if (!el || el === d.body || el === d.documentElement)
|
|
48
|
+
return d === doc ? null : "frame";
|
|
49
|
+
return el;
|
|
50
|
+
}
|
|
25
51
|
/**
|
|
26
52
|
* Whether some text names a filter or a facet, as page-side source: one of its
|
|
27
53
|
* words, split at camelCase and punctuation, is filter(s) or facet(s). Whole
|
|
@@ -63,20 +89,47 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
63
89
|
const p = parseColor(c);
|
|
64
90
|
return p ? "rgba(" + p[0] + ", " + p[1] + ", " + p[2] + ", " + p[3] + ")" : "unknown";
|
|
65
91
|
};
|
|
92
|
+
// Replaced content that paints pixels the audit cannot read: an <img>,
|
|
93
|
+
// <picture>, <video> or <canvas>, often absolutely positioned under a hero
|
|
94
|
+
// heading as a sibling or cousin of the text rather than its ancestor.
|
|
95
|
+
let media = null;
|
|
96
|
+
const mediaUnder = (el, within) => {
|
|
97
|
+
if (media === null) media = Array.from(document.querySelectorAll("img, picture, video, canvas")).slice(0, 300);
|
|
98
|
+
const r = el.getBoundingClientRect();
|
|
99
|
+
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
100
|
+
for (const m of media) {
|
|
101
|
+
// The text's own subtree holds inline icons, not its background; and an
|
|
102
|
+
// image outside the opaque ancestor the text sits on is treated as hidden
|
|
103
|
+
// behind it. An opaque overlay that is NOT an ancestor (a sibling scrim)
|
|
104
|
+
// is not seen, so text on it over an image is skipped, not measured.
|
|
105
|
+
if (el.contains(m) || (within && !within.contains(m))) continue;
|
|
106
|
+
const mr = m.getBoundingClientRect();
|
|
107
|
+
if (mr.width === 0 || mr.height === 0 || cx < mr.left || cx > mr.right || cy < mr.top || cy > mr.bottom) continue;
|
|
108
|
+
const ms = window.getComputedStyle(m);
|
|
109
|
+
if (ms.visibility === "hidden" || ms.display === "none" || ms.opacity === "0") continue;
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
};
|
|
66
114
|
// Effective background: COMPOSITE translucent ancestor layers (a chip with
|
|
67
115
|
// rgba(...,0.12) over white is nearly white — treating it as opaque produces
|
|
68
116
|
// false WCAG failures). Stops at the first opaque layer; white fallback.
|
|
117
|
+
// "image" when an ancestor's background-image or a media element under the
|
|
118
|
+
// text's centre paints behind it: no single colour stands for that, so the
|
|
119
|
+
// contrast check skips it rather than report a ratio against the page.
|
|
69
120
|
const effBg = (el) => {
|
|
70
121
|
const layers = [];
|
|
71
122
|
let node = el;
|
|
123
|
+
let opaque = null;
|
|
72
124
|
for (let i = 0; node && i < 20; i++, node = node.parentElement) {
|
|
73
125
|
const s = window.getComputedStyle(node);
|
|
74
126
|
if (s.backgroundImage && s.backgroundImage !== "none") return "image";
|
|
75
127
|
const c = parseColor(s.backgroundColor);
|
|
76
128
|
if (!c || c[3] === 0) continue;
|
|
77
129
|
layers.push(c);
|
|
78
|
-
if (c[3] >= 1) break;
|
|
130
|
+
if (c[3] >= 1) { opaque = node; break; }
|
|
79
131
|
}
|
|
132
|
+
if (mediaUnder(el, opaque)) return "image";
|
|
80
133
|
let base = [255, 255, 255];
|
|
81
134
|
for (let i = layers.length - 1; i >= 0; i--) {
|
|
82
135
|
const [r, g, b, a] = layers[i];
|
|
@@ -264,7 +317,7 @@ export const DESIGN_COLLECT_SCRIPT = `(() => {
|
|
|
264
317
|
if (img.naturalWidth <= 1 || img.naturalHeight <= 1 || r.width < 24 || r.height < 24) continue;
|
|
265
318
|
const tid = img.getAttribute("data-testid");
|
|
266
319
|
const label = tid ? "[" + tid + "]" : (img.getAttribute("alt") || (img.getAttribute("src") || "").split("/").pop() || "img").slice(0, 40);
|
|
267
|
-
images.push({ label, nw: img.naturalWidth, nh: img.naturalHeight, rw: Math.round(r.width), rh: Math.round(r.height) });
|
|
320
|
+
images.push({ label, nw: img.naturalWidth, nh: img.naturalHeight, rw: Math.round(r.width), rh: Math.round(r.height), fit: window.getComputedStyle(img).objectFit || "fill" });
|
|
268
321
|
}
|
|
269
322
|
const page = {
|
|
270
323
|
scrollW: Math.max(document.documentElement.scrollWidth, document.body ? document.body.scrollWidth : 0),
|
|
@@ -574,6 +627,20 @@ export function spacedApart(r, others) {
|
|
|
574
627
|
export function tooSmall(r, all) {
|
|
575
628
|
return undersized(r) && !spacedApart(r, all);
|
|
576
629
|
}
|
|
630
|
+
/** Past this, a stretched image reads as distorted rather than as rounding. */
|
|
631
|
+
export const ASPECT_TOLERANCE = 0.12;
|
|
632
|
+
/**
|
|
633
|
+
* How far an image is stretched out of its proportions, or null when it is not.
|
|
634
|
+
* Only `fill`, the default, stretches the picture to its box: `cover` and `contain`
|
|
635
|
+
* scale it uniformly and crop or letterbox, `none` draws it at its own size and
|
|
636
|
+
* `scale-down` is one of those two. So a mismatched box is distortion only under `fill`.
|
|
637
|
+
*/
|
|
638
|
+
export function aspectDistortion(img) {
|
|
639
|
+
if (img.fit !== "fill")
|
|
640
|
+
return null;
|
|
641
|
+
const off = Math.abs(img.rw / img.rh / (img.nw / img.nh) - 1);
|
|
642
|
+
return off > ASPECT_TOLERANCE ? off : null;
|
|
643
|
+
}
|
|
577
644
|
/**
|
|
578
645
|
* Reduce raw style records + page facts to a judgeable design digest + score.
|
|
579
646
|
*
|
|
@@ -884,7 +951,10 @@ export function analyzeDesign(payload, viewport, chromeKeys = new Set()) {
|
|
|
884
951
|
if (affordances.length > 0)
|
|
885
952
|
sections.push(`AFFORDANCES:\n` + affordances.map((s) => ` ${s}`).join("\n"));
|
|
886
953
|
// ---- 12. IMAGES: aspect-ratio distortion (rendered box fights the source's proportions).
|
|
887
|
-
const distorted = page.images.
|
|
954
|
+
const distorted = page.images.flatMap((img) => {
|
|
955
|
+
const off = aspectDistortion(img);
|
|
956
|
+
return off === null ? [] : [{ img, off }];
|
|
957
|
+
});
|
|
888
958
|
if (distorted.length > 0) {
|
|
889
959
|
sections.push(`IMAGES (${distorted.length} distorted):\n` +
|
|
890
960
|
distorted
|
|
@@ -177,6 +177,22 @@ export function keyAliases(bases) {
|
|
|
177
177
|
});
|
|
178
178
|
return out;
|
|
179
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* keyAliases for every step between name rules, from each element's base key
|
|
182
|
+
* under each rule, rule 1 first and the current rule last: rule r (from 2)
|
|
183
|
+
* → the step's aliases, current key under r → key under r - 1. A step that
|
|
184
|
+
* renamed nothing is left out.
|
|
185
|
+
*/
|
|
186
|
+
export function keyAliasSteps(bases) {
|
|
187
|
+
const rules = bases.length > 0 ? bases[0].byRule.length : 0;
|
|
188
|
+
const out = {};
|
|
189
|
+
for (let i = 1; i < rules; i++) {
|
|
190
|
+
const step = keyAliases(bases.map((b) => ({ base: b.byRule[i], prior: b.byRule[i - 1], tracked: b.tracked })));
|
|
191
|
+
if (Object.keys(step).length > 0)
|
|
192
|
+
out[i + 1] = step;
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
180
196
|
/**
|
|
181
197
|
* Fingerprint a UI state: normalized route + hash of the set of interactable
|
|
182
198
|
* element classes. Same page with different data → same fingerprint; a page
|
package/dist/engine/memory.js
CHANGED
|
@@ -7,9 +7,11 @@ import { addReading, addVerdict, MAX_TICKETS_KEPT, mergeTicketData } from "./tic
|
|
|
7
7
|
/**
|
|
8
8
|
* The element-name rule current keys are made under. 2: a link or button with
|
|
9
9
|
* no text is named by its image content (an <img>'s alt text, an <svg>'s
|
|
10
|
-
* aria-label or <title>) before its title attribute.
|
|
10
|
+
* aria-label or <title>) before its title attribute. 3: that content also
|
|
11
|
+
* counts a descendant's aria-label and an <img>'s title when its alt is empty
|
|
12
|
+
* (collector.ts CONTENT_SAYS_SRC).
|
|
11
13
|
*/
|
|
12
|
-
export const NAME_RULE =
|
|
14
|
+
export const NAME_RULE = 3;
|
|
13
15
|
/**
|
|
14
16
|
* The kinds a finding can be. One list: scout_finding's input schema, the lane
|
|
15
17
|
* report a parallel agent hands back, and the skill text all read it from
|
|
@@ -278,6 +280,20 @@ export function rekeyed(elements, aliases) {
|
|
|
278
280
|
}
|
|
279
281
|
return out;
|
|
280
282
|
}
|
|
283
|
+
/** A route's aliases for the step that leads to `rule`: key under rule - 1 → key under rule. */
|
|
284
|
+
function stepAliases(data, rule, route) {
|
|
285
|
+
return rule === 2 ? data.keyAliases?.[route] : data.ruleKeyAliases?.[String(rule)]?.[route];
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* A state's elements moved from the keys of name rule `from` to those of rule
|
|
289
|
+
* `to`, one step at a time, each through the route's aliases for that step.
|
|
290
|
+
*/
|
|
291
|
+
function keysUnderRule(elements, from, to, data, route) {
|
|
292
|
+
let out = elements;
|
|
293
|
+
for (let rule = from + 1; rule <= to; rule++)
|
|
294
|
+
out = rekeyed(out, stepAliases(data, rule, route));
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
281
297
|
/**
|
|
282
298
|
* Fold another process's memory into ours, losing nothing from either side.
|
|
283
299
|
*
|
|
@@ -300,6 +316,15 @@ export function mergeMemory(mine, theirs) {
|
|
|
300
316
|
}
|
|
301
317
|
if (Object.keys(out.keyAliases).length === 0)
|
|
302
318
|
delete out.keyAliases;
|
|
319
|
+
out.ruleKeyAliases = {};
|
|
320
|
+
for (const rule of new Set([...Object.keys(theirs.ruleKeyAliases ?? {}), ...Object.keys(mine.ruleKeyAliases ?? {})])) {
|
|
321
|
+
const step = { ...(theirs.ruleKeyAliases?.[rule] ?? {}) };
|
|
322
|
+
for (const [route, aliases] of Object.entries(mine.ruleKeyAliases?.[rule] ?? {}))
|
|
323
|
+
step[route] = { ...aliases, ...(step[route] ?? {}) };
|
|
324
|
+
out.ruleKeyAliases[rule] = step;
|
|
325
|
+
}
|
|
326
|
+
if (Object.keys(out.ruleKeyAliases).length === 0)
|
|
327
|
+
delete out.ruleKeyAliases;
|
|
303
328
|
out.states = { ...theirs.states };
|
|
304
329
|
for (const [fp, ours] of Object.entries(mine.states)) {
|
|
305
330
|
const other = theirs.states[fp];
|
|
@@ -308,10 +333,10 @@ export function mergeMemory(mine, theirs) {
|
|
|
308
333
|
continue;
|
|
309
334
|
}
|
|
310
335
|
const nameRule = Math.max(ours.nameRule ?? 0, other.nameRule ?? 0);
|
|
311
|
-
// When one side is under
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
const current = (side) =>
|
|
336
|
+
// When one side is under a later name rule than the other, the other's
|
|
337
|
+
// keys are moved to that rule's before the union, as a revisit moves them
|
|
338
|
+
// (MemoryStore visitState).
|
|
339
|
+
const current = (side) => keysUnderRule(side.elements, side.nameRule ?? 1, Math.max(nameRule, 1), out, routeIdentity(side.route));
|
|
315
340
|
const elements = { ...current(other) };
|
|
316
341
|
for (const [key, el] of Object.entries(current(ours))) {
|
|
317
342
|
const prev = elements[key];
|
|
@@ -384,6 +409,7 @@ export function mergeMemory(mine, theirs) {
|
|
|
384
409
|
const unionRecord = (a, b) => (a || b ? { ...(b ?? {}), ...(a ?? {}) } : undefined);
|
|
385
410
|
out.discoveredRoutes = unionRecord(mine.discoveredRoutes, theirs.discoveredRoutes);
|
|
386
411
|
out.attemptedRoutes = unionRecord(mine.attemptedRoutes, theirs.attemptedRoutes);
|
|
412
|
+
out.resourceRoutes = unionRecord(mine.resourceRoutes, theirs.resourceRoutes);
|
|
387
413
|
out.designElements = unionRecord(mine.designElements, theirs.designElements);
|
|
388
414
|
out.pageScores = { ...(theirs.pageScores ?? {}) };
|
|
389
415
|
for (const [route, score] of Object.entries(mine.pageScores ?? {})) {
|
|
@@ -1289,6 +1315,31 @@ export class MemoryStore {
|
|
|
1289
1315
|
get discoveredRoutes() {
|
|
1290
1316
|
return this.data.discoveredRoutes ?? {};
|
|
1291
1317
|
}
|
|
1318
|
+
/**
|
|
1319
|
+
* Record that a route answered with a feed, a file or data (crawl.ts
|
|
1320
|
+
* isNonPageResource). A route harvested from a link joins the contract before
|
|
1321
|
+
* anything knows what it serves; this takes it back out, for every role and
|
|
1322
|
+
* every later run, so a feed linked from every page is not a gap forever.
|
|
1323
|
+
*/
|
|
1324
|
+
markResource(route, mediaType) {
|
|
1325
|
+
const map = this.data.resourceRoutes ?? {};
|
|
1326
|
+
if (map[route] !== mediaType) {
|
|
1327
|
+
map[route] = mediaType;
|
|
1328
|
+
this.data.resourceRoutes = map;
|
|
1329
|
+
this.save();
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/** A route that has since answered as a page is one again: back into the contract. */
|
|
1333
|
+
clearResource(route) {
|
|
1334
|
+
const map = this.data.resourceRoutes;
|
|
1335
|
+
if (map && route in map) {
|
|
1336
|
+
delete map[route];
|
|
1337
|
+
this.save();
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
get resourceRoutes() {
|
|
1341
|
+
return this.data.resourceRoutes ?? {};
|
|
1342
|
+
}
|
|
1292
1343
|
/**
|
|
1293
1344
|
* Record that a route was attempted but landed elsewhere (e.g. auth-redirect).
|
|
1294
1345
|
*
|
|
@@ -1827,9 +1878,10 @@ export class MemoryStore {
|
|
|
1827
1878
|
* listed keys a user cannot act on (collector inertKeys): they stay known,
|
|
1828
1879
|
* so a click on one still registers, but coverage does not count them.
|
|
1829
1880
|
* `session` names who visited, for this run's per-session coverage.
|
|
1830
|
-
* `aliases` maps
|
|
1831
|
-
*
|
|
1832
|
-
* states written before
|
|
1881
|
+
* `aliases` maps, for each step between name rules, a listed key to the
|
|
1882
|
+
* key the same control had under the rule before (KeyAliasSteps); the route
|
|
1883
|
+
* keeps them so states written before a rule changed are read under
|
|
1884
|
+
* current keys.
|
|
1833
1885
|
*/
|
|
1834
1886
|
visitState(fingerprint, url, route, elementKeys, inertKeys = [], session, aliases = {}) {
|
|
1835
1887
|
this.runStates.add(fingerprint);
|
|
@@ -1856,7 +1908,7 @@ export class MemoryStore {
|
|
|
1856
1908
|
// named like a text button beside it) leaves it unchanged. Its keys are
|
|
1857
1909
|
// moved to the current ones before it is marked current.
|
|
1858
1910
|
if ((rec.nameRule ?? 1) < NAME_RULE)
|
|
1859
|
-
rec.elements =
|
|
1911
|
+
rec.elements = keysUnderRule(rec.elements, rec.nameRule ?? 1, NAME_RULE, this.data, routeIdentity(route));
|
|
1860
1912
|
rec.nameRule = NAME_RULE;
|
|
1861
1913
|
const present = new Set(elementKeys);
|
|
1862
1914
|
const inert = new Set(inertKeys);
|
|
@@ -1888,16 +1940,18 @@ export class MemoryStore {
|
|
|
1888
1940
|
* hand out ordinals differently, and the states written under the earlier
|
|
1889
1941
|
* rule do not change, so neither should what their keys are read as.
|
|
1890
1942
|
*/
|
|
1891
|
-
learnAliases(route,
|
|
1892
|
-
const pairs = Object.entries(aliases);
|
|
1893
|
-
if (pairs.length === 0)
|
|
1894
|
-
return;
|
|
1895
|
-
const byRoute = (this.data.keyAliases ??= {});
|
|
1943
|
+
learnAliases(route, steps) {
|
|
1896
1944
|
const id = routeIdentity(route);
|
|
1897
|
-
const
|
|
1898
|
-
|
|
1899
|
-
if (
|
|
1900
|
-
|
|
1945
|
+
for (const [rule, aliases] of Object.entries(steps)) {
|
|
1946
|
+
const pairs = Object.entries(aliases);
|
|
1947
|
+
if (pairs.length === 0)
|
|
1948
|
+
continue;
|
|
1949
|
+
const byRoute = rule === "2" ? (this.data.keyAliases ??= {}) : ((this.data.ruleKeyAliases ??= {})[rule] ??= {});
|
|
1950
|
+
const known = (byRoute[id] ??= {});
|
|
1951
|
+
for (const [key, prior] of pairs)
|
|
1952
|
+
if (!Object.hasOwn(known, prior))
|
|
1953
|
+
known[prior] = key;
|
|
1954
|
+
}
|
|
1901
1955
|
}
|
|
1902
1956
|
markExercised(fingerprint, key, action) {
|
|
1903
1957
|
const rec = this.data.states[fingerprint];
|
|
@@ -2265,7 +2319,7 @@ export class MemoryStore {
|
|
|
2265
2319
|
const route = routeIdentity(rec.route);
|
|
2266
2320
|
const keys = only && !only.has(fp) ? null : bucket(listed, route);
|
|
2267
2321
|
// A state written under an earlier name rule is read under current keys.
|
|
2268
|
-
const elements = (rec.nameRule ?? 1
|
|
2322
|
+
const elements = keysUnderRule(rec.elements, rec.nameRule ?? 1, NAME_RULE, this.data, route);
|
|
2269
2323
|
for (const [key, v] of Object.entries(elements)) {
|
|
2270
2324
|
if (v.inert)
|
|
2271
2325
|
bucket(inert, route).add(key);
|
package/dist/engine/policy.js
CHANGED
|
@@ -887,6 +887,37 @@ export function matchReadPost(entries, appUrl, url) {
|
|
|
887
887
|
const segments = target.pathname.split("/").filter(Boolean);
|
|
888
888
|
return (entries.find((e) => (e.origin ?? app) === target.origin && e.segments.length === segments.length && e.segments.every((seg, i) => seg === "*" || seg === segments[i])) ?? null);
|
|
889
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* The page a script's POST was sent from, for the gap ledger's list of pages
|
|
892
|
+
* observe refused a POST on. The Referer of a request the top document sent,
|
|
893
|
+
* when it is a page of the app; otherwise the page the session drives when the
|
|
894
|
+
* request is seen. A frame's Referer is its own document, which the session
|
|
895
|
+
* never navigates to, so the caller passes none for a frame's request.
|
|
896
|
+
*
|
|
897
|
+
* The Referer comes first because the session's page URL can lag: a page's
|
|
898
|
+
* script can send its first request before the browser has told Playwright
|
|
899
|
+
* that the page committed, so the session still reports the page it came
|
|
900
|
+
* from. A Referer that is the app's bare origin is set aside for the session's
|
|
901
|
+
* page: an app whose referrer policy sends only the origin would otherwise
|
|
902
|
+
* charge every page's POST to "/". The price is that the app's root page,
|
|
903
|
+
* whose own Referer is that bare origin, still relies on the session's page
|
|
904
|
+
* URL, lag and all.
|
|
905
|
+
*/
|
|
906
|
+
export function refusedPostPage(appUrl, referer, pageUrl) {
|
|
907
|
+
if (!referer)
|
|
908
|
+
return pageUrl;
|
|
909
|
+
let from;
|
|
910
|
+
try {
|
|
911
|
+
from = new URL(referer);
|
|
912
|
+
if (from.origin !== new URL(appUrl).origin)
|
|
913
|
+
return pageUrl;
|
|
914
|
+
}
|
|
915
|
+
catch {
|
|
916
|
+
return pageUrl;
|
|
917
|
+
}
|
|
918
|
+
const bareOrigin = from.pathname === "/" && !from.search;
|
|
919
|
+
return bareOrigin && pageUrl ? pageUrl : referer;
|
|
920
|
+
}
|
|
890
921
|
/** The longest body read for a GraphQL operation; a longer one cannot be vetted and is refused. */
|
|
891
922
|
export const MAX_READ_POST_BODY = 100_000;
|
|
892
923
|
/** A GraphQL operation that writes: `mutation` or `subscription`, then an optional name, then its variables, directives or selection. */
|