tether-ui 0.2.0 → 0.4.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/README.md +124 -2
- package/dist/approvals/match.d.ts +14 -0
- package/dist/approvals/match.js +216 -0
- package/dist/approvals/storage.d.ts +4 -0
- package/dist/approvals/storage.js +44 -0
- package/dist/cli/approvals.d.ts +2 -0
- package/dist/cli/approvals.js +113 -0
- package/dist/cli/format.d.ts +2 -2
- package/dist/cli/format.js +25 -5
- package/dist/cli/index.js +30 -21
- package/dist/cli/json.d.ts +62 -3
- package/dist/cli/json.js +66 -40
- package/dist/contract/describe.d.ts +2 -0
- package/dist/contract/describe.js +6 -0
- package/dist/contract/infer.d.ts +2 -2
- package/dist/contract/infer.js +69 -2
- package/dist/contract/read.d.ts +3 -0
- package/dist/contract/read.js +67 -0
- package/dist/contract/scopes.d.ts +5 -0
- package/dist/contract/scopes.js +150 -0
- package/dist/drift/compare.d.ts +4 -1
- package/dist/drift/compare.js +97 -33
- package/dist/drift/evidence.js +1 -0
- package/dist/scanner/collect.d.ts +4 -2
- package/dist/scanner/collect.js +61 -4
- package/dist/scanner/scan.js +127 -61
- package/dist/types.d.ts +103 -2
- package/dist/utils/config.js +18 -2
- package/dist/utils/coverage.d.ts +3 -0
- package/dist/utils/coverage.js +28 -0
- package/dist/utils/scenarios.d.ts +24 -0
- package/dist/utils/scenarios.js +82 -0
- package/dist/utils/storage.js +8 -3
- package/package.json +4 -3
- package/report/out/404/index.html +1 -1
- package/report/out/404.html +1 -1
- package/report/out/__next.__PAGE__.txt +3 -3
- package/report/out/__next._full.txt +3 -3
- package/report/out/__next._tree.txt +2 -2
- package/report/out/_next/static/chunks/330-b092d894bdb2103c.js +1 -0
- package/report/out/_next/static/chunks/app/_not-found/{page-032313fc4524b472.js → page-172b410568146bc8.js} +1 -1
- package/report/out/_next/static/chunks/app/contract/{page-60f6d610ece63f59.js → page-f49364851a596fef.js} +1 -1
- package/report/out/_next/static/chunks/app/drift/{page-655683d6032a79a8.js → page-e0f2eda1e981d86f.js} +1 -1
- package/report/out/_next/static/chunks/app/layout-9a410c21fc0ba3c9.js +1 -0
- package/report/out/_next/static/chunks/app/{page-bdfcce47bd844e7e.js → page-e399a35da3e55f00.js} +1 -1
- package/report/out/_next/static/chunks/app/viewports/{page-6e44561586c30871.js → page-5a01227414ac4842.js} +1 -1
- package/report/out/_next/static/chunks/{main-app-e3082ce30f616b80.js → main-app-e7e077df7256067f.js} +1 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-94732500710e6d55.js +1 -0
- package/report/out/_next/static/css/b754cdc60c7b43bb.css +1 -0
- package/report/out/_not-found/__next._full.txt +2 -2
- package/report/out/_not-found/__next._not-found.__PAGE__.txt +2 -2
- package/report/out/_not-found/__next._tree.txt +2 -2
- package/report/out/_not-found/index.html +1 -1
- package/report/out/_not-found/index.txt +2 -2
- package/report/out/contract/__next._full.txt +3 -3
- package/report/out/contract/__next._tree.txt +2 -2
- package/report/out/contract/__next.contract.__PAGE__.txt +3 -3
- package/report/out/contract/index.html +1 -1
- package/report/out/contract/index.txt +3 -3
- package/report/out/drift/__next._full.txt +3 -3
- package/report/out/drift/__next._tree.txt +2 -2
- package/report/out/drift/__next.drift.__PAGE__.txt +3 -3
- package/report/out/drift/index.html +1 -1
- package/report/out/drift/index.txt +3 -3
- package/report/out/index.html +1 -1
- package/report/out/index.txt +3 -3
- package/report/out/viewports/__next._full.txt +3 -3
- package/report/out/viewports/__next._tree.txt +2 -2
- package/report/out/viewports/__next.viewports.__PAGE__.txt +3 -3
- package/report/out/viewports/index.html +1 -1
- package/report/out/viewports/index.txt +3 -3
- package/report/out/_next/static/chunks/679-ea0b2616e7a83e6d.js +0 -1
- package/report/out/_next/static/chunks/app/layout-16ba70b55e65864f.js +0 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-d142454d28d73f8d.js +0 -1
- package/report/out/_next/static/css/5f0345b3d96e2143.css +0 -1
- /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → TBRjO9Fdl2ToZdlLQ7gIf}/_buildManifest.js +0 -0
- /package/report/out/_next/static/{MCyQw-tRQ7-eGxwnJHR0Q → TBRjO9Fdl2ToZdlLQ7gIf}/_ssgManifest.js +0 -0
package/dist/drift/compare.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeUrl } from "../utils/scenarios.js";
|
|
2
|
+
import { resolveRule } from "../contract/scopes.js";
|
|
1
3
|
import { createHash } from "node:crypto";
|
|
2
4
|
import { baselineMatcher } from "../scanner/identity.js";
|
|
3
5
|
import { findingEvidence } from "./evidence.js";
|
|
@@ -41,35 +43,42 @@ function equivalent(category, a, b) {
|
|
|
41
43
|
/^-?[\d.]+px$/.test(b) &&
|
|
42
44
|
Math.abs(parseFloat(a) - parseFloat(b)) <= 0.25);
|
|
43
45
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
/** Atomic observations are retained until approval matching is complete. */
|
|
47
|
+
export function collectOccurrences(contract, baseline, current) {
|
|
48
|
+
const occurrences = [];
|
|
49
|
+
let identities = new Map(), selectors = new Map();
|
|
50
|
+
let state = "default";
|
|
51
|
+
let documentOverflowUnique = false, completeSnapshot = true;
|
|
46
52
|
function push(issue, viewport) {
|
|
47
|
-
|
|
48
|
-
issue
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (!previous.property.split(", ").includes(issue.property))
|
|
62
|
-
previous.property += `, ${issue.property}`;
|
|
63
|
-
}
|
|
64
|
-
else
|
|
65
|
-
issues.set(key, {
|
|
66
|
-
...issue,
|
|
67
|
-
id: createHash("sha256").update(key).digest("hex").slice(0, 12),
|
|
68
|
-
viewports: [viewport],
|
|
69
|
-
});
|
|
53
|
+
occurrences.push({
|
|
54
|
+
issue: { ...issue, ...(state !== "default" ? { state } : {}) },
|
|
55
|
+
viewport,
|
|
56
|
+
state,
|
|
57
|
+
identityUnique: completeSnapshot &&
|
|
58
|
+
issue.identityConfidence === "high" &&
|
|
59
|
+
identities.get(issue.identity ?? "") === 1,
|
|
60
|
+
selectorUnique: completeSnapshot &&
|
|
61
|
+
(selectors.get(issue.selector) === 1 ||
|
|
62
|
+
(issue.category === "layout" &&
|
|
63
|
+
issue.selector === "html" &&
|
|
64
|
+
issue.property === "overflow" &&
|
|
65
|
+
documentOverflowUnique)),
|
|
66
|
+
});
|
|
70
67
|
}
|
|
71
68
|
for (const snapshot of current.snapshots) {
|
|
72
|
-
|
|
69
|
+
state = snapshot.state ?? "default";
|
|
70
|
+
completeSnapshot = !snapshot.truncated;
|
|
71
|
+
identities = new Map();
|
|
72
|
+
selectors = new Map();
|
|
73
|
+
for (const element of snapshot.elements) {
|
|
74
|
+
if (element.identity)
|
|
75
|
+
identities.set(element.identity, (identities.get(element.identity) ?? 0) + 1);
|
|
76
|
+
selectors.set(element.selector, (selectors.get(element.selector) ?? 0) + 1);
|
|
77
|
+
}
|
|
78
|
+
documentOverflowUnique =
|
|
79
|
+
snapshot.layout.filter((p) => p.selector === "html" && p.kind === "overflow").length === 1;
|
|
80
|
+
const previousSnapshot = baseline.snapshots.find((s) => safeUrl(s.route) === safeUrl(snapshot.route) &&
|
|
81
|
+
(s.state ?? "default") === state &&
|
|
73
82
|
s.viewport.name === snapshot.viewport.name &&
|
|
74
83
|
s.viewport.width === snapshot.viewport.width &&
|
|
75
84
|
s.viewport.height === snapshot.viewport.height);
|
|
@@ -77,14 +86,20 @@ export function compare(contract, baseline, current) {
|
|
|
77
86
|
for (const element of snapshot.elements) {
|
|
78
87
|
const prior = match(element);
|
|
79
88
|
for (const o of element.observations) {
|
|
80
|
-
const
|
|
89
|
+
const rule = resolveRule(contract, element, o);
|
|
90
|
+
const expected = (rule?.tokens ?? contract[o.category]).map((t) => t.value);
|
|
81
91
|
if (expected.some((v) => equivalent(o.category, v, o.value)))
|
|
82
92
|
continue;
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
|
|
93
|
+
if (rule?.source !== "override" &&
|
|
94
|
+
(contract.version === 1 ||
|
|
95
|
+
(prior?.scope?.name === element.scope?.name &&
|
|
96
|
+
prior?.scope?.part === element.scope?.part)) &&
|
|
97
|
+
prior?.observations.some((p) => p.category === o.category &&
|
|
98
|
+
p.property === o.property &&
|
|
99
|
+
equivalent(o.category, p.value, o.value)))
|
|
86
100
|
continue;
|
|
87
101
|
push({
|
|
102
|
+
...(rule ? { rule } : {}),
|
|
88
103
|
category: o.category,
|
|
89
104
|
severity: severityFor(o.category, o.value, expected),
|
|
90
105
|
title: labels[o.category],
|
|
@@ -98,7 +113,7 @@ export function compare(contract, baseline, current) {
|
|
|
98
113
|
current: o.value,
|
|
99
114
|
expected,
|
|
100
115
|
route: snapshot.route,
|
|
101
|
-
}, snapshot.viewport
|
|
116
|
+
}, snapshot.viewport);
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
119
|
for (const problem of snapshot.layout) {
|
|
@@ -121,15 +136,64 @@ export function compare(contract, baseline, current) {
|
|
|
121
136
|
current: problem.value,
|
|
122
137
|
expected: ["Content fits within the viewport"],
|
|
123
138
|
route: snapshot.route,
|
|
124
|
-
}, snapshot.viewport
|
|
139
|
+
}, snapshot.viewport);
|
|
125
140
|
}
|
|
126
141
|
}
|
|
142
|
+
return occurrences;
|
|
143
|
+
}
|
|
144
|
+
export function groupOccurrences(contract, occurrences) {
|
|
145
|
+
const issues = new Map();
|
|
146
|
+
function push(issue, viewport) {
|
|
147
|
+
const key = [
|
|
148
|
+
issue.category,
|
|
149
|
+
issue.route,
|
|
150
|
+
issue.identity ?? issue.selector,
|
|
151
|
+
issue.current,
|
|
152
|
+
issue.title,
|
|
153
|
+
]
|
|
154
|
+
.concat(issue.state && issue.state !== "default" ? [issue.state] : [])
|
|
155
|
+
.concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
|
|
156
|
+
.concat(issue.approval ? [issue.property, issue.approval.id] : [])
|
|
157
|
+
.join("\0");
|
|
158
|
+
const previous = issues.get(key);
|
|
159
|
+
if (previous) {
|
|
160
|
+
if (!previous.viewports.includes(viewport))
|
|
161
|
+
previous.viewports.push(viewport);
|
|
162
|
+
for (const evidence of issue.evidence ?? [])
|
|
163
|
+
if (!previous.evidence?.some((e) => e.viewport.name === evidence.viewport.name))
|
|
164
|
+
previous.evidence?.push(evidence);
|
|
165
|
+
if (!previous.property.split(", ").includes(issue.property))
|
|
166
|
+
previous.property += `, ${issue.property}`;
|
|
167
|
+
}
|
|
168
|
+
else
|
|
169
|
+
issues.set(key, {
|
|
170
|
+
...issue,
|
|
171
|
+
...(issue.evidence ? { evidence: [...issue.evidence] } : {}),
|
|
172
|
+
id: createHash("sha256").update(key).digest("hex").slice(0, 12),
|
|
173
|
+
viewports: [viewport],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
for (const occurrence of occurrences)
|
|
177
|
+
push(occurrence.issue, occurrence.viewport.name);
|
|
127
178
|
const rank = { high: 0, medium: 1, low: 2 };
|
|
179
|
+
if (contract.version === 2)
|
|
180
|
+
for (const issue of issues.values()) {
|
|
181
|
+
issue.viewports.sort();
|
|
182
|
+
issue.evidence?.sort((a, b) => a.viewport.name.localeCompare(b.viewport.name));
|
|
183
|
+
}
|
|
128
184
|
return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
|
|
129
185
|
a.category.localeCompare(b.category) ||
|
|
130
186
|
a.route.localeCompare(b.route) ||
|
|
187
|
+
(a.state ?? "default").localeCompare(b.state ?? "default") ||
|
|
131
188
|
(a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
|
|
132
|
-
a.current.localeCompare(b.current)
|
|
189
|
+
a.current.localeCompare(b.current) ||
|
|
190
|
+
(contract.version === 2
|
|
191
|
+
? a.property.localeCompare(b.property) ||
|
|
192
|
+
JSON.stringify(a.rule ?? null).localeCompare(JSON.stringify(b.rule ?? null))
|
|
193
|
+
: 0));
|
|
194
|
+
}
|
|
195
|
+
export function compare(contract, baseline, current) {
|
|
196
|
+
return groupOccurrences(contract, collectOccurrences(contract, baseline, current));
|
|
133
197
|
}
|
|
134
198
|
export function consistencyScore(issues) {
|
|
135
199
|
return Math.max(0, 100 -
|
package/dist/drift/evidence.js
CHANGED
|
@@ -24,6 +24,7 @@ export function visibleBounds(bounds, viewport) {
|
|
|
24
24
|
export function findingEvidence(current, baseline, element, prior) {
|
|
25
25
|
return {
|
|
26
26
|
viewport: current.viewport,
|
|
27
|
+
...(current.state ? { state: current.state } : {}),
|
|
27
28
|
current: {
|
|
28
29
|
screenshot: current.screenshot,
|
|
29
30
|
...(element?.bounds ? { bounds: element.bounds } : {}),
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { CollectedElement } from "./identity.js";
|
|
2
|
-
import type { LayoutProblem } from "../types.js";
|
|
2
|
+
import type { LayoutProblem, ScopeDefinition } from "../types.js";
|
|
3
3
|
/** Pure DOM reader, serialized by Playwright into the page. */
|
|
4
|
-
export declare function collect({ maxElements, ignore, }: {
|
|
4
|
+
export declare function collect({ maxElements, ignore, scopes, sensitiveSelectors, }: {
|
|
5
5
|
maxElements: number;
|
|
6
6
|
ignore: string[];
|
|
7
|
+
scopes?: ScopeDefinition[];
|
|
8
|
+
sensitiveSelectors?: string[];
|
|
7
9
|
}): {
|
|
8
10
|
elements: CollectedElement[];
|
|
9
11
|
layout: LayoutProblem[];
|
package/dist/scanner/collect.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Pure DOM reader, serialized by Playwright into the page. */
|
|
2
|
-
export function collect({ maxElements, ignore, }) {
|
|
2
|
+
export function collect({ maxElements, ignore, scopes = [], sensitiveSelectors = [], }) {
|
|
3
3
|
for (const selector of ignore) {
|
|
4
4
|
try {
|
|
5
5
|
document.querySelector(selector);
|
|
@@ -8,6 +8,59 @@ export function collect({ maxElements, ignore, }) {
|
|
|
8
8
|
throw new Error(`Invalid ignore selector: ${selector}`);
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
const sensitive = sensitiveSelectors.flatMap((selector) => [
|
|
12
|
+
...document.querySelectorAll(selector),
|
|
13
|
+
]);
|
|
14
|
+
function privateElement(el) {
|
|
15
|
+
return (sensitive.some((s) => s.contains(el) || el.contains(s)) ||
|
|
16
|
+
(el.getAttribute("aria-labelledby") ?? "").split(/\s+/).some((id) => {
|
|
17
|
+
const label = document.getElementById(id);
|
|
18
|
+
return (label && sensitive.some((s) => s.contains(label) || label.contains(s)));
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
// Validate declarations even when the page contains no matching elements.
|
|
22
|
+
for (const scope of scopes) {
|
|
23
|
+
for (const selector of [
|
|
24
|
+
scope.selector,
|
|
25
|
+
...Object.values(scope.parts ?? {}),
|
|
26
|
+
]) {
|
|
27
|
+
try {
|
|
28
|
+
document.querySelector(selector);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`Invalid selector in scope ${scope.name}: ${selector}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function scopeFor(el) {
|
|
36
|
+
for (let root = el; root; root = root.parentElement) {
|
|
37
|
+
const matches = scopes.filter((s) => root.matches(s.selector));
|
|
38
|
+
if (matches.length > 1)
|
|
39
|
+
throw new Error(`Ambiguous scopes on ${selectorFor(root)}: ${matches
|
|
40
|
+
.map((s) => s.name)
|
|
41
|
+
.sort()
|
|
42
|
+
.join(", ")}`);
|
|
43
|
+
if (!matches.length)
|
|
44
|
+
continue;
|
|
45
|
+
const scope = matches[0];
|
|
46
|
+
for (let node = el; node; node = node.parentElement) {
|
|
47
|
+
const parts = Object.entries(scope.parts ?? {}).filter(([, selector]) => node.matches(selector));
|
|
48
|
+
if (parts.length > 1)
|
|
49
|
+
throw new Error(`Ambiguous parts in ${scope.name} on ${selectorFor(node)}.`);
|
|
50
|
+
if (parts.length || node === root) {
|
|
51
|
+
// Unnamed descendants do not inherit container padding/radius conventions.
|
|
52
|
+
if (!parts.length && el !== root)
|
|
53
|
+
return;
|
|
54
|
+
return {
|
|
55
|
+
name: scope.name,
|
|
56
|
+
...(scope.variant ? { variant: scope.variant } : {}),
|
|
57
|
+
part: parts[0]?.[0] ?? "root",
|
|
58
|
+
instance: selectorFor(root),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
11
64
|
const excluded = ignore.length ? ignore.join(",") : null;
|
|
12
65
|
function visible(el) {
|
|
13
66
|
if (excluded && el.closest(excluded))
|
|
@@ -36,6 +89,8 @@ export function collect({ maxElements, ignore, }) {
|
|
|
36
89
|
.join("");
|
|
37
90
|
}
|
|
38
91
|
function selectorFor(el) {
|
|
92
|
+
if (privateElement(el))
|
|
93
|
+
return structureFor(el);
|
|
39
94
|
if (el.id && document.querySelectorAll(`#${escapeCss(el.id)}`).length === 1)
|
|
40
95
|
return `#${escapeCss(el.id)}`;
|
|
41
96
|
const testid = el.getAttribute("data-testid");
|
|
@@ -160,15 +215,17 @@ export function collect({ maxElements, ignore, }) {
|
|
|
160
215
|
.split(/\s+/)
|
|
161
216
|
.map((id) => document.getElementById(id)?.textContent ?? "")
|
|
162
217
|
.join(" ");
|
|
218
|
+
const scope = scopeFor(el);
|
|
163
219
|
elements.push({
|
|
220
|
+
...(scope ? { scope } : {}),
|
|
164
221
|
selector,
|
|
165
222
|
tag: el.tagName.toLowerCase(),
|
|
166
223
|
observations,
|
|
167
224
|
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
168
225
|
signals: {
|
|
169
|
-
attributes,
|
|
170
|
-
text: el.textContent ?? "",
|
|
171
|
-
classes: [...el.classList],
|
|
226
|
+
attributes: privateElement(el) ? {} : attributes,
|
|
227
|
+
text: privateElement(el) ? "" : (el.textContent ?? ""),
|
|
228
|
+
classes: privateElement(el) ? [] : [...el.classList],
|
|
172
229
|
structure: structureFor(el),
|
|
173
230
|
},
|
|
174
231
|
});
|
package/dist/scanner/scan.js
CHANGED
|
@@ -1,89 +1,155 @@
|
|
|
1
1
|
import { openBrowser } from "./browser.js";
|
|
2
2
|
import { assignIdentities } from "./identity.js";
|
|
3
3
|
import { collect } from "./collect.js";
|
|
4
|
-
import { mkdir } from "node:fs/promises";
|
|
4
|
+
import { mkdir, rm, readFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { normalizeValue } from "../utils/normalize.js";
|
|
8
|
+
import { captures, safeUrl } from "../utils/scenarios.js";
|
|
8
9
|
import { targetUrl } from "../utils/config.js";
|
|
9
10
|
export async function scan(config, directory, phase) {
|
|
10
|
-
|
|
11
|
+
let storageState;
|
|
12
|
+
if (config.storageState) {
|
|
13
|
+
try {
|
|
14
|
+
storageState = JSON.parse(await readFile(config.storageState, "utf8"));
|
|
15
|
+
if (!storageState ||
|
|
16
|
+
typeof storageState !== "object" ||
|
|
17
|
+
Array.isArray(storageState))
|
|
18
|
+
throw new Error();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
const first = captures(config)[0];
|
|
22
|
+
throw new Error(`Scan failed: route ${safeUrl(first.route)}, state ${first.scenario.id}, viewport ${config.viewports[0].name}, step context. Could not read a valid storage-state file.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
11
25
|
const browser = await openBrowser();
|
|
26
|
+
let complete = false;
|
|
12
27
|
const result = {
|
|
13
28
|
version: 1,
|
|
14
29
|
createdAt: new Date().toISOString(),
|
|
15
|
-
url: config.url,
|
|
30
|
+
url: safeUrl(config.url),
|
|
16
31
|
snapshots: [],
|
|
17
32
|
};
|
|
18
33
|
try {
|
|
19
|
-
|
|
34
|
+
await mkdir(join(directory, "screenshots", phase), { recursive: true });
|
|
35
|
+
for (const { route, scenario } of captures(config))
|
|
20
36
|
for (const viewport of config.viewports) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
colorScheme: "light",
|
|
25
|
-
reducedMotion: "reduce",
|
|
26
|
-
});
|
|
27
|
-
const page = await context.newPage();
|
|
28
|
-
const url = targetUrl(config.url, route);
|
|
37
|
+
let context;
|
|
38
|
+
let step = "context";
|
|
39
|
+
let timer;
|
|
29
40
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
await Promise.race([
|
|
42
|
+
(async () => {
|
|
43
|
+
context = await browser.newContext({
|
|
44
|
+
...(storageState ? { storageState } : {}),
|
|
45
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
46
|
+
deviceScaleFactor: 1,
|
|
47
|
+
colorScheme: scenario.colorScheme ?? "light",
|
|
48
|
+
reducedMotion: "reduce",
|
|
49
|
+
});
|
|
50
|
+
const page = await context.newPage();
|
|
51
|
+
page.setDefaultTimeout(Math.min(10000, scenario.timeoutMs ?? 30000));
|
|
52
|
+
page.setDefaultNavigationTimeout(Math.min(30000, scenario.timeoutMs ?? 30000));
|
|
53
|
+
step = "setup";
|
|
54
|
+
await scenario.setup?.(page, context);
|
|
55
|
+
step = "navigation";
|
|
56
|
+
const url = targetUrl(config.url, route);
|
|
57
|
+
try {
|
|
58
|
+
const response = await page.goto(url, {
|
|
59
|
+
waitUntil: "load",
|
|
60
|
+
timeout: 30000,
|
|
61
|
+
});
|
|
62
|
+
if (response && response.status() >= 400)
|
|
63
|
+
throw new Error(`HTTP ${response.status()}`);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error("Navigation failed");
|
|
67
|
+
}
|
|
68
|
+
step = "interaction";
|
|
69
|
+
await scenario.interact?.(page);
|
|
70
|
+
step = "readiness";
|
|
71
|
+
await scenario.ready(page);
|
|
72
|
+
step = "capture";
|
|
73
|
+
await page.addStyleTag({
|
|
74
|
+
content: "*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }",
|
|
75
|
+
});
|
|
76
|
+
await page.evaluate(() => Promise.race([
|
|
77
|
+
document.fonts.ready,
|
|
78
|
+
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
79
|
+
]));
|
|
80
|
+
await page.waitForTimeout(config.settleMs);
|
|
81
|
+
const collected = await page.evaluate(collect, {
|
|
82
|
+
maxElements: config.maxElements,
|
|
83
|
+
ignore: config.ignore,
|
|
84
|
+
scopes: config.scopes,
|
|
85
|
+
sensitiveSelectors: config.sensitiveSelectors,
|
|
86
|
+
});
|
|
87
|
+
if (!collected.elements.length)
|
|
88
|
+
throw new Error("No visible elements found");
|
|
89
|
+
for (const element of collected.elements) {
|
|
90
|
+
element.observations = [
|
|
91
|
+
...new Map(element.observations.map((o) => {
|
|
92
|
+
const normalized = {
|
|
93
|
+
...o,
|
|
94
|
+
value: normalizeValue(o.property, o.value),
|
|
95
|
+
};
|
|
96
|
+
return [
|
|
97
|
+
`${o.category}\0${o.property}\0${normalized.value}`,
|
|
98
|
+
normalized,
|
|
99
|
+
];
|
|
100
|
+
})).values(),
|
|
101
|
+
].filter((o) => o.value !== "transparent" && o.value !== "0px");
|
|
102
|
+
}
|
|
103
|
+
const filename = `${createHash("sha256")
|
|
104
|
+
.update(scenario.id === "default"
|
|
105
|
+
? route
|
|
106
|
+
: JSON.stringify([route, scenario.id]))
|
|
107
|
+
.digest("hex")
|
|
108
|
+
.slice(0, 12)}-${viewport.name}.png`;
|
|
109
|
+
const screenshot = `screenshots/${phase}/${filename}`;
|
|
110
|
+
await page.screenshot({
|
|
111
|
+
path: join(directory, screenshot),
|
|
112
|
+
fullPage: false,
|
|
113
|
+
animations: "disabled",
|
|
114
|
+
mask: (config.sensitiveSelectors ?? []).map((selector) => page.locator(selector)),
|
|
115
|
+
maskColor: "#000000",
|
|
116
|
+
});
|
|
117
|
+
result.snapshots.push({
|
|
118
|
+
route: safeUrl(route),
|
|
119
|
+
...(scenario.id !== "default" ? { state: scenario.id } : {}),
|
|
120
|
+
...(scenario.colorScheme === "dark"
|
|
121
|
+
? { colorScheme: "dark" }
|
|
122
|
+
: {}),
|
|
123
|
+
viewport,
|
|
124
|
+
...collected,
|
|
125
|
+
elements: assignIdentities(collected.elements),
|
|
126
|
+
screenshot,
|
|
127
|
+
});
|
|
128
|
+
})(),
|
|
129
|
+
new Promise((_, reject) => {
|
|
130
|
+
timer = setTimeout(() => reject(new Error("timeout")), scenario.timeoutMs ?? 30000);
|
|
131
|
+
}),
|
|
132
|
+
]);
|
|
36
133
|
}
|
|
37
134
|
catch {
|
|
38
|
-
|
|
135
|
+
// Never forward Playwright errors: they can contain locator text, cookies or submitted values.
|
|
136
|
+
throw new Error(`Scan failed: route ${safeUrl(route)}, state ${scenario.id}, viewport ${viewport.name}, step ${step}. Check setup/readiness and timeoutMs; previous artifacts were preserved.`);
|
|
39
137
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
document.fonts.ready,
|
|
45
|
-
new Promise((resolve) => setTimeout(resolve, 5000)),
|
|
46
|
-
]));
|
|
47
|
-
await page.waitForTimeout(config.settleMs);
|
|
48
|
-
const collected = await page.evaluate(collect, {
|
|
49
|
-
maxElements: config.maxElements,
|
|
50
|
-
ignore: config.ignore,
|
|
51
|
-
});
|
|
52
|
-
if (!collected.elements.length)
|
|
53
|
-
throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);
|
|
54
|
-
for (const element of collected.elements) {
|
|
55
|
-
element.observations = [
|
|
56
|
-
...new Map(element.observations.map((o) => {
|
|
57
|
-
const normalized = {
|
|
58
|
-
...o,
|
|
59
|
-
value: normalizeValue(o.property, o.value),
|
|
60
|
-
};
|
|
61
|
-
return [
|
|
62
|
-
`${o.category}\0${o.property}\0${normalized.value}`,
|
|
63
|
-
normalized,
|
|
64
|
-
];
|
|
65
|
-
})).values(),
|
|
66
|
-
].filter((o) => o.value !== "transparent" && o.value !== "0px");
|
|
138
|
+
finally {
|
|
139
|
+
if (timer)
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
await context?.close().catch(() => { });
|
|
67
142
|
}
|
|
68
|
-
const filename = `${createHash("sha256").update(route).digest("hex").slice(0, 12)}-${viewport.name}.png`;
|
|
69
|
-
const screenshot = `screenshots/${phase}/${filename}`;
|
|
70
|
-
await page.screenshot({
|
|
71
|
-
path: join(directory, screenshot),
|
|
72
|
-
fullPage: false,
|
|
73
|
-
animations: "disabled",
|
|
74
|
-
});
|
|
75
|
-
result.snapshots.push({
|
|
76
|
-
route,
|
|
77
|
-
viewport,
|
|
78
|
-
...collected,
|
|
79
|
-
elements: assignIdentities(collected.elements),
|
|
80
|
-
screenshot,
|
|
81
|
-
});
|
|
82
|
-
await context.close();
|
|
83
143
|
}
|
|
144
|
+
complete = true;
|
|
84
145
|
return result;
|
|
85
146
|
}
|
|
86
147
|
finally {
|
|
87
148
|
await browser.close();
|
|
149
|
+
if (!complete)
|
|
150
|
+
await rm(join(directory, "screenshots", phase), {
|
|
151
|
+
recursive: true,
|
|
152
|
+
force: true,
|
|
153
|
+
});
|
|
88
154
|
}
|
|
89
155
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -6,7 +6,19 @@ export interface Viewport {
|
|
|
6
6
|
width: number;
|
|
7
7
|
height: number;
|
|
8
8
|
}
|
|
9
|
+
export interface Scenario {
|
|
10
|
+
id: string;
|
|
11
|
+
routes: string[];
|
|
12
|
+
colorScheme?: "light" | "dark";
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
setup?: (page: import("playwright").Page, context: import("playwright").BrowserContext) => Promise<void>;
|
|
15
|
+
interact?: (page: import("playwright").Page) => Promise<void>;
|
|
16
|
+
ready: (page: import("playwright").Page) => Promise<void>;
|
|
17
|
+
}
|
|
9
18
|
export interface Config {
|
|
19
|
+
scenarios?: Scenario[];
|
|
20
|
+
storageState?: string;
|
|
21
|
+
sensitiveSelectors?: string[];
|
|
10
22
|
url: string;
|
|
11
23
|
routes: string[];
|
|
12
24
|
viewports: Viewport[];
|
|
@@ -14,6 +26,44 @@ export interface Config {
|
|
|
14
26
|
maxElements: number;
|
|
15
27
|
settleMs: number;
|
|
16
28
|
ignore: string[];
|
|
29
|
+
scopes?: ScopeDefinition[];
|
|
30
|
+
}
|
|
31
|
+
export interface ScopeDefinition {
|
|
32
|
+
name: string;
|
|
33
|
+
selector: string;
|
|
34
|
+
variant?: string;
|
|
35
|
+
minOccurrences?: number;
|
|
36
|
+
parts?: Record<string, string>;
|
|
37
|
+
overrides?: {
|
|
38
|
+
part?: string;
|
|
39
|
+
category: Category;
|
|
40
|
+
property: string;
|
|
41
|
+
values: string[];
|
|
42
|
+
}[];
|
|
43
|
+
}
|
|
44
|
+
export interface ScopeMembership {
|
|
45
|
+
name: string;
|
|
46
|
+
variant?: string;
|
|
47
|
+
part: string;
|
|
48
|
+
instance: string;
|
|
49
|
+
}
|
|
50
|
+
export interface ScopedRule {
|
|
51
|
+
scope: string;
|
|
52
|
+
part: string;
|
|
53
|
+
category: Category;
|
|
54
|
+
property: string;
|
|
55
|
+
samples: number;
|
|
56
|
+
tokens: Token[];
|
|
57
|
+
source: "scope" | "override";
|
|
58
|
+
}
|
|
59
|
+
export interface RuleProvenance {
|
|
60
|
+
scope?: string;
|
|
61
|
+
variant?: string;
|
|
62
|
+
part?: string;
|
|
63
|
+
source: "scope" | "override" | "global";
|
|
64
|
+
reason?: "unscoped" | "missing-rule" | "insufficient-support" | "partial-baseline";
|
|
65
|
+
samples: number;
|
|
66
|
+
tokens: Token[];
|
|
17
67
|
}
|
|
18
68
|
export interface Observation {
|
|
19
69
|
category: Category;
|
|
@@ -28,6 +78,7 @@ export interface Bounds {
|
|
|
28
78
|
height: number;
|
|
29
79
|
}
|
|
30
80
|
export interface Evidence {
|
|
81
|
+
state?: string;
|
|
31
82
|
viewport: Viewport;
|
|
32
83
|
current: {
|
|
33
84
|
screenshot: string;
|
|
@@ -39,6 +90,7 @@ export interface Evidence {
|
|
|
39
90
|
};
|
|
40
91
|
}
|
|
41
92
|
export interface ElementRecord {
|
|
93
|
+
scope?: ScopeMembership;
|
|
42
94
|
identity?: string;
|
|
43
95
|
identityConfidence?: IdentityConfidence;
|
|
44
96
|
bounds?: Bounds;
|
|
@@ -53,6 +105,8 @@ export interface LayoutProblem {
|
|
|
53
105
|
severity: Severity;
|
|
54
106
|
}
|
|
55
107
|
export interface Snapshot {
|
|
108
|
+
state?: string;
|
|
109
|
+
colorScheme?: "light" | "dark";
|
|
56
110
|
route: string;
|
|
57
111
|
viewport: Viewport;
|
|
58
112
|
elements: ElementRecord[];
|
|
@@ -71,11 +125,57 @@ export interface Token {
|
|
|
71
125
|
count: number;
|
|
72
126
|
}
|
|
73
127
|
export type Contract = {
|
|
74
|
-
version: 1;
|
|
128
|
+
version: 1 | 2;
|
|
129
|
+
scopes?: ScopeDefinition[];
|
|
130
|
+
rules?: ScopedRule[];
|
|
131
|
+
partial?: boolean;
|
|
75
132
|
createdAt: string;
|
|
76
133
|
minOccurrences: number;
|
|
77
134
|
} & Record<Category, Token[]>;
|
|
135
|
+
export interface Approval {
|
|
136
|
+
id: string;
|
|
137
|
+
reason: string;
|
|
138
|
+
route: string;
|
|
139
|
+
state: string;
|
|
140
|
+
target: {
|
|
141
|
+
kind: "identity";
|
|
142
|
+
value: string;
|
|
143
|
+
} | {
|
|
144
|
+
kind: "selector";
|
|
145
|
+
value: string;
|
|
146
|
+
identity: string | null;
|
|
147
|
+
};
|
|
148
|
+
category: Category | "layout";
|
|
149
|
+
property: string;
|
|
150
|
+
current: string;
|
|
151
|
+
viewport: Viewport;
|
|
152
|
+
context?: {
|
|
153
|
+
scope: string;
|
|
154
|
+
part: string;
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
export interface ApprovalFile {
|
|
158
|
+
version: 1;
|
|
159
|
+
approvals: Approval[];
|
|
160
|
+
}
|
|
161
|
+
export interface ApprovalSummary {
|
|
162
|
+
approved: Issue[];
|
|
163
|
+
stale: Approval[];
|
|
164
|
+
}
|
|
165
|
+
export interface FindingOccurrence {
|
|
166
|
+
issue: Omit<Issue, "id" | "viewports">;
|
|
167
|
+
viewport: Viewport;
|
|
168
|
+
state: string;
|
|
169
|
+
identityUnique: boolean;
|
|
170
|
+
selectorUnique: boolean;
|
|
171
|
+
}
|
|
78
172
|
export interface Issue {
|
|
173
|
+
state?: string;
|
|
174
|
+
approval?: {
|
|
175
|
+
id: string;
|
|
176
|
+
reason: string;
|
|
177
|
+
};
|
|
178
|
+
rule?: RuleProvenance;
|
|
79
179
|
identity?: string;
|
|
80
180
|
identityConfidence?: IdentityConfidence;
|
|
81
181
|
evidence?: Evidence[];
|
|
@@ -91,7 +191,8 @@ export interface Issue {
|
|
|
91
191
|
viewports: string[];
|
|
92
192
|
}
|
|
93
193
|
export interface Report {
|
|
94
|
-
version: 1;
|
|
194
|
+
version: 1 | 2;
|
|
195
|
+
approvals?: ApprovalSummary;
|
|
95
196
|
baselineAt: string;
|
|
96
197
|
scan: Scan;
|
|
97
198
|
contract: Contract;
|