tether-ui 0.3.0 → 0.5.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 +137 -3
- 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 +52 -7
- package/dist/cli/index.js +32 -24
- package/dist/cli/json.d.ts +169 -1
- package/dist/cli/json.js +79 -41
- package/dist/contract/scopes.js +2 -2
- package/dist/drift/compare.d.ts +6 -1
- package/dist/drift/compare.js +95 -30
- package/dist/drift/evidence.js +1 -0
- package/dist/drift/layout-status.d.ts +3 -0
- package/dist/drift/layout-status.js +42 -0
- package/dist/scanner/collect.d.ts +2 -1
- package/dist/scanner/collect.js +16 -4
- package/dist/scanner/scan.js +127 -62
- package/dist/types.d.ts +61 -1
- package/dist/utils/config.js +15 -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 +7 -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/264-98a22092a2a439fb.js +1 -0
- package/report/out/_next/static/chunks/app/_not-found/{page-032313fc4524b472.js → page-bd7ad6814f9f2184.js} +1 -1
- package/report/out/_next/static/chunks/app/contract/{page-60f6d610ece63f59.js → page-35308576f338b58b.js} +1 -1
- package/report/out/_next/static/chunks/app/drift/{page-655683d6032a79a8.js → page-05c4ba0a3b384c21.js} +1 -1
- package/report/out/_next/static/chunks/app/layout-3c4d03405b91a1e8.js +1 -0
- package/report/out/_next/static/chunks/app/{page-bdfcce47bd844e7e.js → page-91a7575f09cd5dab.js} +1 -1
- package/report/out/_next/static/chunks/app/viewports/{page-6e44561586c30871.js → page-aaff424fc51d4e3c.js} +1 -1
- package/report/out/_next/static/chunks/{main-app-e3082ce30f616b80.js → main-app-73ea41a51858a055.js} +1 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-127cd29b1dce4f53.js +1 -0
- package/report/out/_next/static/css/2b0955e8507de202.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-a159f64a448d339d.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/7fbedb5502367c5e.css +0 -1
- /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → G4NOKg8i0CINpWi5hqaJY}/_buildManifest.js +0 -0
- /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → G4NOKg8i0CINpWi5hqaJY}/_ssgManifest.js +0 -0
package/dist/drift/compare.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { existingLayout } from "./layout-status.js";
|
|
2
|
+
import { safeUrl } from "../utils/scenarios.js";
|
|
1
3
|
import { resolveRule } from "../contract/scopes.js";
|
|
2
4
|
import { createHash } from "node:crypto";
|
|
3
5
|
import { baselineMatcher } from "../scanner/identity.js";
|
|
@@ -42,40 +44,53 @@ function equivalent(category, a, b) {
|
|
|
42
44
|
/^-?[\d.]+px$/.test(b) &&
|
|
43
45
|
Math.abs(parseFloat(a) - parseFloat(b)) <= 0.25);
|
|
44
46
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
/** Atomic observations are retained until approval matching is complete. */
|
|
48
|
+
export function collectOccurrences(contract, baseline, current) {
|
|
49
|
+
const occurrences = [];
|
|
50
|
+
let identities = new Map(), selectors = new Map();
|
|
51
|
+
let state = "default";
|
|
52
|
+
let documentOverflowUnique = false, completeSnapshot = true;
|
|
47
53
|
function push(issue, viewport) {
|
|
48
|
-
|
|
49
|
-
issue
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!previous.evidence?.some((e) => e.viewport.name === evidence.viewport.name))
|
|
63
|
-
previous.evidence?.push(evidence);
|
|
64
|
-
if (!previous.property.split(", ").includes(issue.property))
|
|
65
|
-
previous.property += `, ${issue.property}`;
|
|
66
|
-
}
|
|
67
|
-
else
|
|
68
|
-
issues.set(key, {
|
|
69
|
-
...issue,
|
|
70
|
-
id: createHash("sha256").update(key).digest("hex").slice(0, 12),
|
|
71
|
-
viewports: [viewport],
|
|
72
|
-
});
|
|
54
|
+
occurrences.push({
|
|
55
|
+
issue: { ...issue, ...(state !== "default" ? { state } : {}) },
|
|
56
|
+
viewport,
|
|
57
|
+
state,
|
|
58
|
+
identityUnique: completeSnapshot &&
|
|
59
|
+
issue.identityConfidence === "high" &&
|
|
60
|
+
identities.get(issue.identity ?? "") === 1,
|
|
61
|
+
selectorUnique: completeSnapshot &&
|
|
62
|
+
(selectors.get(issue.selector) === 1 ||
|
|
63
|
+
(issue.category === "layout" &&
|
|
64
|
+
issue.selector === "html" &&
|
|
65
|
+
issue.property === "overflow" &&
|
|
66
|
+
documentOverflowUnique)),
|
|
67
|
+
});
|
|
73
68
|
}
|
|
74
69
|
for (const snapshot of current.snapshots) {
|
|
75
|
-
|
|
70
|
+
state = snapshot.state ?? "default";
|
|
71
|
+
completeSnapshot = !snapshot.truncated;
|
|
72
|
+
identities = new Map();
|
|
73
|
+
selectors = new Map();
|
|
74
|
+
for (const element of snapshot.elements) {
|
|
75
|
+
if (element.identity)
|
|
76
|
+
identities.set(element.identity, (identities.get(element.identity) ?? 0) + 1);
|
|
77
|
+
selectors.set(element.selector, (selectors.get(element.selector) ?? 0) + 1);
|
|
78
|
+
}
|
|
79
|
+
documentOverflowUnique =
|
|
80
|
+
snapshot.layout.filter((p) => p.selector === "html" && p.kind === "overflow").length === 1;
|
|
81
|
+
const previousSnapshots = baseline.snapshots.filter((s) => safeUrl(s.route) === safeUrl(snapshot.route) &&
|
|
82
|
+
(s.state ?? "default") === state &&
|
|
83
|
+
(s.colorScheme ?? "light") === (snapshot.colorScheme ?? "light") &&
|
|
76
84
|
s.viewport.name === snapshot.viewport.name &&
|
|
77
85
|
s.viewport.width === snapshot.viewport.width &&
|
|
78
86
|
s.viewport.height === snapshot.viewport.height);
|
|
87
|
+
const previousSnapshot = previousSnapshots.length === 1 ? previousSnapshots[0] : undefined;
|
|
88
|
+
const uniqueCapture = current.snapshots.filter((s) => safeUrl(s.route) === safeUrl(snapshot.route) &&
|
|
89
|
+
(s.state ?? "default") === state &&
|
|
90
|
+
(s.colorScheme ?? "light") === (snapshot.colorScheme ?? "light") &&
|
|
91
|
+
s.viewport.name === snapshot.viewport.name &&
|
|
92
|
+
s.viewport.width === snapshot.viewport.width &&
|
|
93
|
+
s.viewport.height === snapshot.viewport.height).length === 1;
|
|
79
94
|
const match = baselineMatcher(previousSnapshot?.elements ?? []);
|
|
80
95
|
for (const element of snapshot.elements) {
|
|
81
96
|
const prior = match(element);
|
|
@@ -107,13 +122,16 @@ export function compare(contract, baseline, current) {
|
|
|
107
122
|
current: o.value,
|
|
108
123
|
expected,
|
|
109
124
|
route: snapshot.route,
|
|
110
|
-
}, snapshot.viewport
|
|
125
|
+
}, snapshot.viewport);
|
|
111
126
|
}
|
|
112
127
|
}
|
|
113
128
|
for (const problem of snapshot.layout) {
|
|
114
129
|
const element = snapshot.elements.find((e) => e.selector === problem.selector);
|
|
115
130
|
push({
|
|
116
131
|
category: "layout",
|
|
132
|
+
baselineStatus: uniqueCapture && existingLayout(snapshot, previousSnapshot, problem)
|
|
133
|
+
? "existing"
|
|
134
|
+
: "new-or-changed",
|
|
117
135
|
severity: problem.severity,
|
|
118
136
|
title: problem.kind === "overflow"
|
|
119
137
|
? "Horizontal overflow"
|
|
@@ -130,9 +148,46 @@ export function compare(contract, baseline, current) {
|
|
|
130
148
|
current: problem.value,
|
|
131
149
|
expected: ["Content fits within the viewport"],
|
|
132
150
|
route: snapshot.route,
|
|
133
|
-
}, snapshot.viewport
|
|
151
|
+
}, snapshot.viewport);
|
|
134
152
|
}
|
|
135
153
|
}
|
|
154
|
+
return occurrences;
|
|
155
|
+
}
|
|
156
|
+
export function groupOccurrences(contract, occurrences) {
|
|
157
|
+
const issues = new Map();
|
|
158
|
+
function push(issue, viewport) {
|
|
159
|
+
const key = [
|
|
160
|
+
issue.category,
|
|
161
|
+
issue.route,
|
|
162
|
+
issue.identity ?? issue.selector,
|
|
163
|
+
issue.current,
|
|
164
|
+
issue.title,
|
|
165
|
+
]
|
|
166
|
+
.concat(issue.state && issue.state !== "default" ? [issue.state] : [])
|
|
167
|
+
.concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
|
|
168
|
+
.concat(issue.baselineStatus === "existing" ? ["baseline:existing"] : [])
|
|
169
|
+
.concat(issue.approval ? [issue.property, issue.approval.id] : [])
|
|
170
|
+
.join("\0");
|
|
171
|
+
const previous = issues.get(key);
|
|
172
|
+
if (previous) {
|
|
173
|
+
if (!previous.viewports.includes(viewport))
|
|
174
|
+
previous.viewports.push(viewport);
|
|
175
|
+
for (const evidence of issue.evidence ?? [])
|
|
176
|
+
if (!previous.evidence?.some((e) => e.viewport.name === evidence.viewport.name))
|
|
177
|
+
previous.evidence?.push(evidence);
|
|
178
|
+
if (!previous.property.split(", ").includes(issue.property))
|
|
179
|
+
previous.property += `, ${issue.property}`;
|
|
180
|
+
}
|
|
181
|
+
else
|
|
182
|
+
issues.set(key, {
|
|
183
|
+
...issue,
|
|
184
|
+
...(issue.evidence ? { evidence: [...issue.evidence] } : {}),
|
|
185
|
+
id: createHash("sha256").update(key).digest("hex").slice(0, 12),
|
|
186
|
+
viewports: [viewport],
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const occurrence of occurrences)
|
|
190
|
+
push(occurrence.issue, occurrence.viewport.name);
|
|
136
191
|
const rank = { high: 0, medium: 1, low: 2 };
|
|
137
192
|
if (contract.version === 2)
|
|
138
193
|
for (const issue of issues.values()) {
|
|
@@ -142,6 +197,7 @@ export function compare(contract, baseline, current) {
|
|
|
142
197
|
return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
|
|
143
198
|
a.category.localeCompare(b.category) ||
|
|
144
199
|
a.route.localeCompare(b.route) ||
|
|
200
|
+
(a.state ?? "default").localeCompare(b.state ?? "default") ||
|
|
145
201
|
(a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
|
|
146
202
|
a.current.localeCompare(b.current) ||
|
|
147
203
|
(contract.version === 2
|
|
@@ -149,7 +205,16 @@ export function compare(contract, baseline, current) {
|
|
|
149
205
|
JSON.stringify(a.rule ?? null).localeCompare(JSON.stringify(b.rule ?? null))
|
|
150
206
|
: 0));
|
|
151
207
|
}
|
|
208
|
+
export function compare(contract, baseline, current) {
|
|
209
|
+
return groupOccurrences(contract, collectOccurrences(contract, baseline, current));
|
|
210
|
+
}
|
|
152
211
|
export function consistencyScore(issues) {
|
|
153
212
|
return Math.max(0, 100 -
|
|
154
213
|
issues.reduce((sum, i) => sum + { high: 8, medium: 4, low: 2 }[i.severity], 0));
|
|
155
214
|
}
|
|
215
|
+
/** Unknown/legacy findings remain blocking; only verified existing layout is excluded. */
|
|
216
|
+
export function blockingIssues(issues, failOn = "all") {
|
|
217
|
+
return failOn === "new"
|
|
218
|
+
? issues.filter((i) => !(i.category === "layout" && i.baselineStatus === "existing"))
|
|
219
|
+
: issues;
|
|
220
|
+
}
|
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 } : {}),
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { baselineMatcher } from "../scanner/identity.js";
|
|
2
|
+
/** Existing means an exact, unambiguous observation in complete matching captures. */
|
|
3
|
+
export function existingLayout(current, baseline, problem) {
|
|
4
|
+
if (!baseline || current.truncated || baseline.truncated)
|
|
5
|
+
return false;
|
|
6
|
+
const sameKind = (s, selector) => s.layout.filter((p) => p.selector === selector && p.kind === problem.kind);
|
|
7
|
+
if (sameKind(current, problem.selector).length !== 1)
|
|
8
|
+
return false;
|
|
9
|
+
let selector;
|
|
10
|
+
if (problem.selector === "html" && problem.kind === "overflow") {
|
|
11
|
+
selector = "html";
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
const elements = current.elements.filter((e) => e.selector === problem.selector);
|
|
15
|
+
if (elements.length !== 1)
|
|
16
|
+
return false;
|
|
17
|
+
const element = elements[0];
|
|
18
|
+
if (element.identity &&
|
|
19
|
+
current.elements.filter((e) => e.identity === element.identity).length !==
|
|
20
|
+
1)
|
|
21
|
+
return false;
|
|
22
|
+
const prior = baselineMatcher(baseline.elements)(element);
|
|
23
|
+
if (!prior ||
|
|
24
|
+
prior.tag !== element.tag ||
|
|
25
|
+
baseline.elements.filter((e) => e.selector === prior.selector).length !==
|
|
26
|
+
1)
|
|
27
|
+
return false;
|
|
28
|
+
// The same rounded overflow amount alone cannot establish unchanged geometry.
|
|
29
|
+
if (!element.bounds ||
|
|
30
|
+
!prior.bounds ||
|
|
31
|
+
element.bounds.width <= 0 ||
|
|
32
|
+
element.bounds.height <= 0 ||
|
|
33
|
+
!["x", "y", "width", "height"].every((key) => Number.isFinite(element.bounds[key]) &&
|
|
34
|
+
element.bounds[key] === prior.bounds[key]))
|
|
35
|
+
return false;
|
|
36
|
+
selector = prior.selector;
|
|
37
|
+
}
|
|
38
|
+
const previous = sameKind(baseline, selector);
|
|
39
|
+
return (previous.length === 1 &&
|
|
40
|
+
previous[0].value === problem.value &&
|
|
41
|
+
previous[0].severity === problem.severity);
|
|
42
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { CollectedElement } from "./identity.js";
|
|
2
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, scopes, }: {
|
|
4
|
+
export declare function collect({ maxElements, ignore, scopes, sensitiveSelectors, }: {
|
|
5
5
|
maxElements: number;
|
|
6
6
|
ignore: string[];
|
|
7
7
|
scopes?: ScopeDefinition[];
|
|
8
|
+
sensitiveSelectors?: string[];
|
|
8
9
|
}): {
|
|
9
10
|
elements: CollectedElement[];
|
|
10
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, scopes = [], }) {
|
|
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,16 @@ export function collect({ maxElements, ignore, scopes = [], }) {
|
|
|
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
|
+
}
|
|
11
21
|
// Validate declarations even when the page contains no matching elements.
|
|
12
22
|
for (const scope of scopes) {
|
|
13
23
|
for (const selector of [
|
|
@@ -79,6 +89,8 @@ export function collect({ maxElements, ignore, scopes = [], }) {
|
|
|
79
89
|
.join("");
|
|
80
90
|
}
|
|
81
91
|
function selectorFor(el) {
|
|
92
|
+
if (privateElement(el))
|
|
93
|
+
return structureFor(el);
|
|
82
94
|
if (el.id && document.querySelectorAll(`#${escapeCss(el.id)}`).length === 1)
|
|
83
95
|
return `#${escapeCss(el.id)}`;
|
|
84
96
|
const testid = el.getAttribute("data-testid");
|
|
@@ -211,9 +223,9 @@ export function collect({ maxElements, ignore, scopes = [], }) {
|
|
|
211
223
|
observations,
|
|
212
224
|
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
213
225
|
signals: {
|
|
214
|
-
attributes,
|
|
215
|
-
text: el.textContent ?? "",
|
|
216
|
-
classes: [...el.classList],
|
|
226
|
+
attributes: privateElement(el) ? {} : attributes,
|
|
227
|
+
text: privateElement(el) ? "" : (el.textContent ?? ""),
|
|
228
|
+
classes: privateElement(el) ? [] : [...el.classList],
|
|
217
229
|
structure: structureFor(el),
|
|
218
230
|
},
|
|
219
231
|
});
|
package/dist/scanner/scan.js
CHANGED
|
@@ -1,90 +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
|
-
scopes: config.scopes,
|
|
52
|
-
});
|
|
53
|
-
if (!collected.elements.length)
|
|
54
|
-
throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);
|
|
55
|
-
for (const element of collected.elements) {
|
|
56
|
-
element.observations = [
|
|
57
|
-
...new Map(element.observations.map((o) => {
|
|
58
|
-
const normalized = {
|
|
59
|
-
...o,
|
|
60
|
-
value: normalizeValue(o.property, o.value),
|
|
61
|
-
};
|
|
62
|
-
return [
|
|
63
|
-
`${o.category}\0${o.property}\0${normalized.value}`,
|
|
64
|
-
normalized,
|
|
65
|
-
];
|
|
66
|
-
})).values(),
|
|
67
|
-
].filter((o) => o.value !== "transparent" && o.value !== "0px");
|
|
138
|
+
finally {
|
|
139
|
+
if (timer)
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
await context?.close().catch(() => { });
|
|
68
142
|
}
|
|
69
|
-
const filename = `${createHash("sha256").update(route).digest("hex").slice(0, 12)}-${viewport.name}.png`;
|
|
70
|
-
const screenshot = `screenshots/${phase}/${filename}`;
|
|
71
|
-
await page.screenshot({
|
|
72
|
-
path: join(directory, screenshot),
|
|
73
|
-
fullPage: false,
|
|
74
|
-
animations: "disabled",
|
|
75
|
-
});
|
|
76
|
-
result.snapshots.push({
|
|
77
|
-
route,
|
|
78
|
-
viewport,
|
|
79
|
-
...collected,
|
|
80
|
-
elements: assignIdentities(collected.elements),
|
|
81
|
-
screenshot,
|
|
82
|
-
});
|
|
83
|
-
await context.close();
|
|
84
143
|
}
|
|
144
|
+
complete = true;
|
|
85
145
|
return result;
|
|
86
146
|
}
|
|
87
147
|
finally {
|
|
88
148
|
await browser.close();
|
|
149
|
+
if (!complete)
|
|
150
|
+
await rm(join(directory, "screenshots", phase), {
|
|
151
|
+
recursive: true,
|
|
152
|
+
force: true,
|
|
153
|
+
});
|
|
89
154
|
}
|
|
90
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[];
|
|
@@ -66,6 +78,7 @@ export interface Bounds {
|
|
|
66
78
|
height: number;
|
|
67
79
|
}
|
|
68
80
|
export interface Evidence {
|
|
81
|
+
state?: string;
|
|
69
82
|
viewport: Viewport;
|
|
70
83
|
current: {
|
|
71
84
|
screenshot: string;
|
|
@@ -92,6 +105,8 @@ export interface LayoutProblem {
|
|
|
92
105
|
severity: Severity;
|
|
93
106
|
}
|
|
94
107
|
export interface Snapshot {
|
|
108
|
+
state?: string;
|
|
109
|
+
colorScheme?: "light" | "dark";
|
|
95
110
|
route: string;
|
|
96
111
|
viewport: Viewport;
|
|
97
112
|
elements: ElementRecord[];
|
|
@@ -117,7 +132,50 @@ export type Contract = {
|
|
|
117
132
|
createdAt: string;
|
|
118
133
|
minOccurrences: number;
|
|
119
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
|
+
}
|
|
120
172
|
export interface Issue {
|
|
173
|
+
baselineStatus?: "existing" | "new-or-changed";
|
|
174
|
+
state?: string;
|
|
175
|
+
approval?: {
|
|
176
|
+
id: string;
|
|
177
|
+
reason: string;
|
|
178
|
+
};
|
|
121
179
|
rule?: RuleProvenance;
|
|
122
180
|
identity?: string;
|
|
123
181
|
identityConfidence?: IdentityConfidence;
|
|
@@ -134,7 +192,9 @@ export interface Issue {
|
|
|
134
192
|
viewports: string[];
|
|
135
193
|
}
|
|
136
194
|
export interface Report {
|
|
137
|
-
|
|
195
|
+
failOn?: "all" | "new";
|
|
196
|
+
version: 1 | 2;
|
|
197
|
+
approvals?: ApprovalSummary;
|
|
138
198
|
baselineAt: string;
|
|
139
199
|
scan: Scan;
|
|
140
200
|
contract: Contract;
|
package/dist/utils/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { validateScopes } from "../contract/scopes.js";
|
|
2
2
|
import { access } from "node:fs/promises";
|
|
3
|
-
import {
|
|
3
|
+
import { validateScenarios, safeUrl } from "./scenarios.js";
|
|
4
|
+
import { resolve, dirname, sep } from "node:path";
|
|
4
5
|
import { createJiti } from "jiti";
|
|
5
6
|
export const defaults = {
|
|
6
7
|
url: "http://localhost:3000",
|
|
@@ -36,7 +37,9 @@ export async function loadConfig(cwd, url, file) {
|
|
|
36
37
|
throw new Error("Configuration must export an object.");
|
|
37
38
|
const config = { ...defaults, ...input, ...(url ? { url } : {}) };
|
|
38
39
|
try {
|
|
39
|
-
if (!["http:", "https:"].includes(new URL(config.url).protocol)
|
|
40
|
+
if (!["http:", "https:"].includes(new URL(config.url).protocol) ||
|
|
41
|
+
new URL(config.url).username ||
|
|
42
|
+
new URL(config.url).password)
|
|
40
43
|
throw 0;
|
|
41
44
|
}
|
|
42
45
|
catch {
|
|
@@ -76,6 +79,16 @@ export async function loadConfig(cwd, url, file) {
|
|
|
76
79
|
if (config.scopes !== undefined)
|
|
77
80
|
config.scopes = validateScopes(config.scopes);
|
|
78
81
|
config.routes = [...new Set(config.routes)];
|
|
82
|
+
validateScenarios(config);
|
|
83
|
+
if (config.storageState) {
|
|
84
|
+
config.storageState = resolve(dirname(path), config.storageState);
|
|
85
|
+
if (config.storageState.startsWith(resolve(cwd, ".tether") + sep))
|
|
86
|
+
throw new Error("Keep storageState outside .tether; use an ignored .auth directory.");
|
|
87
|
+
if (config.routes.some((r) => !config.scenarios?.some((s) => s.routes.includes(r))))
|
|
88
|
+
throw new Error("Every authenticated route needs a scenario with an explicit ready assertion.");
|
|
89
|
+
}
|
|
90
|
+
if (new Set(config.routes.map((r) => safeUrl(r))).size !== config.routes.length)
|
|
91
|
+
throw new Error("Routes must remain distinct after query-value redaction; use named scenarios for variants.");
|
|
79
92
|
return config;
|
|
80
93
|
}
|
|
81
94
|
export function targetUrl(base, route) {
|