tether-ui 0.1.0 → 0.3.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 +82 -23
- package/dist/cli/format.js +4 -3
- package/dist/cli/index.js +21 -8
- package/dist/cli/json.d.ts +41 -0
- package/dist/cli/json.js +59 -0
- 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 +70 -3
- 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.js +52 -18
- package/dist/drift/evidence.d.ts +4 -0
- package/dist/drift/evidence.js +41 -0
- package/dist/scanner/collect.d.ts +5 -3
- package/dist/scanner/collect.js +78 -2
- package/dist/scanner/identity.d.ts +15 -0
- package/dist/scanner/identity.js +86 -0
- package/dist/scanner/scan.js +9 -1
- package/dist/types.d.ts +68 -1
- package/dist/utils/config.js +3 -0
- package/package.json +13 -4
- 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 +4 -4
- package/report/out/__next._tree.txt +2 -2
- package/report/out/_next/static/chunks/679-a159f64a448d339d.js +1 -0
- package/report/out/_next/static/chunks/app/_not-found/{page-e3039971fa1f5f8d.js → page-032313fc4524b472.js} +1 -1
- package/report/out/_next/static/chunks/app/contract/{page-340f86944045adea.js → page-60f6d610ece63f59.js} +1 -1
- package/report/out/_next/static/chunks/app/drift/{page-559460ac1a19903b.js → page-655683d6032a79a8.js} +1 -1
- package/report/out/_next/static/chunks/app/layout-16ba70b55e65864f.js +1 -0
- package/report/out/_next/static/chunks/app/{page-ab86e7319bb488c8.js → page-bdfcce47bd844e7e.js} +1 -1
- package/report/out/_next/static/chunks/app/viewports/{page-8970bc5bd4e2e4f2.js → page-6e44561586c30871.js} +1 -1
- package/report/out/_next/static/chunks/main-app-e3082ce30f616b80.js +1 -0
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-d142454d28d73f8d.js +1 -0
- package/report/out/_next/static/css/7fbedb5502367c5e.css +1 -0
- package/report/out/_not-found/__next._full.txt +3 -3
- 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 +3 -3
- package/report/out/contract/__next._full.txt +4 -4
- 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 +4 -4
- package/report/out/drift/__next._full.txt +4 -4
- 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 +4 -4
- package/report/out/index.html +1 -1
- package/report/out/index.txt +4 -4
- package/report/out/viewports/__next._full.txt +4 -4
- 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 +4 -4
- package/report/out/_next/static/chunks/100-cde31512e90ea4ee.js +0 -1
- package/report/out/_next/static/chunks/app/layout-6c6cbe0311c0f643.js +0 -1
- package/report/out/_next/static/chunks/main-app-91d98c37495a2d56.js +0 -1
- package/report/out/_next/static/chunks/next/dist/client/components/builtin/global-error-657c3d8e42266fe3.js +0 -1
- package/report/out/_next/static/css/b85889345c486052.css +0 -1
- /package/report/out/_next/static/{NEP_BMzhg1ixudz9w2f3I → l8qlXIaCzWbRx2ZWOTp_i}/_buildManifest.js +0 -0
- /package/report/out/_next/static/{NEP_BMzhg1ixudz9w2f3I → l8qlXIaCzWbRx2ZWOTp_i}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { categories, } from "../types.js";
|
|
2
|
+
import { normalizeValue } from "../utils/normalize.js";
|
|
3
|
+
const name = (s) => typeof s === "string" && /^[a-zA-Z][\w-]{0,63}$/.test(s);
|
|
4
|
+
const object = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
5
|
+
const selector = (v) => typeof v === "string" && !!v.trim();
|
|
6
|
+
export function validProperty(category, property) {
|
|
7
|
+
if (typeof property !== "string")
|
|
8
|
+
return false;
|
|
9
|
+
const properties = {
|
|
10
|
+
colors: /^(color|background-color|border-color)$/,
|
|
11
|
+
fontFamilies: /^font-family$/,
|
|
12
|
+
fontSizes: /^font-size$/,
|
|
13
|
+
fontWeights: /^font-weight$/,
|
|
14
|
+
lineHeights: /^line-height$/,
|
|
15
|
+
spacing: /^(padding|margin)-(top|right|bottom|left)$|^(row|column)-gap$/,
|
|
16
|
+
radii: /^border-radius$/,
|
|
17
|
+
shadows: /^box-shadow$/,
|
|
18
|
+
borders: /^border$/,
|
|
19
|
+
elementHeights: /^height$/,
|
|
20
|
+
};
|
|
21
|
+
return !!properties[category]?.test(property);
|
|
22
|
+
}
|
|
23
|
+
/** Canonical, serializable declarations. CSS syntax is checked in the browser. */
|
|
24
|
+
export function validateScopes(input) {
|
|
25
|
+
if (!Array.isArray(input))
|
|
26
|
+
throw new Error("Config scopes must be an array.");
|
|
27
|
+
const names = new Set();
|
|
28
|
+
const selectors = new Set();
|
|
29
|
+
const result = [];
|
|
30
|
+
for (const s of input) {
|
|
31
|
+
if (!object(s) ||
|
|
32
|
+
!name(s.name) ||
|
|
33
|
+
!selector(s.selector) ||
|
|
34
|
+
names.has(s.name) ||
|
|
35
|
+
selectors.has(s.selector.trim()))
|
|
36
|
+
throw new Error("Scopes need unique names and distinct, nonempty CSS selectors.");
|
|
37
|
+
if (Object.keys(s).some((k) => ![
|
|
38
|
+
"name",
|
|
39
|
+
"selector",
|
|
40
|
+
"variant",
|
|
41
|
+
"minOccurrences",
|
|
42
|
+
"parts",
|
|
43
|
+
"overrides",
|
|
44
|
+
].includes(k)))
|
|
45
|
+
throw new Error(`Unknown setting in scope ${s.name}.`);
|
|
46
|
+
if (s.variant !== undefined && !name(s.variant))
|
|
47
|
+
throw new Error(`Invalid variant for scope ${s.name}.`);
|
|
48
|
+
if (s.minOccurrences !== undefined &&
|
|
49
|
+
(!Number.isInteger(s.minOccurrences) ||
|
|
50
|
+
Number(s.minOccurrences) < 2 ||
|
|
51
|
+
Number(s.minOccurrences) > 1000))
|
|
52
|
+
throw new Error(`Scope ${s.name} minOccurrences must be between 2 and 1000; use an explicit override for a singleton.`);
|
|
53
|
+
if (s.parts !== undefined &&
|
|
54
|
+
(!object(s.parts) ||
|
|
55
|
+
Object.entries(s.parts).some(([k, v]) => !name(k) || k === "root" || !selector(v))))
|
|
56
|
+
throw new Error(`Scope ${s.name} parts need names (other than root) and CSS selectors.`);
|
|
57
|
+
const parts = Object.fromEntries(Object.entries(s.parts ?? {}).sort(([a], [b]) => a.localeCompare(b)));
|
|
58
|
+
if (new Set(Object.values(parts).map((v) => v.trim())).size !==
|
|
59
|
+
Object.keys(parts).length)
|
|
60
|
+
throw new Error(`Duplicate part selectors in ${s.name}.`);
|
|
61
|
+
if (s.overrides !== undefined && !Array.isArray(s.overrides))
|
|
62
|
+
throw new Error(`Scope ${s.name} overrides must be an array.`);
|
|
63
|
+
const keys = new Set();
|
|
64
|
+
const overrides = [];
|
|
65
|
+
for (const o of (s.overrides ?? [])) {
|
|
66
|
+
if (!object(o) ||
|
|
67
|
+
!categories.includes(o.category) ||
|
|
68
|
+
!validProperty(String(o.category), o.property) ||
|
|
69
|
+
!Array.isArray(o.values) ||
|
|
70
|
+
!o.values.length ||
|
|
71
|
+
o.values.some((v) => typeof v !== "string" || !v.trim()))
|
|
72
|
+
throw new Error(`Invalid property, category or values in ${s.name} override.`);
|
|
73
|
+
if (Object.keys(o).some((k) => !["part", "category", "property", "values"].includes(k)))
|
|
74
|
+
throw new Error(`Unknown override setting in ${s.name}.`);
|
|
75
|
+
const part = o.part ?? "root";
|
|
76
|
+
if (typeof part !== "string" ||
|
|
77
|
+
(part !== "root" && !Object.hasOwn(parts, part)))
|
|
78
|
+
throw new Error(`Unknown override part in ${s.name}.`);
|
|
79
|
+
const key = JSON.stringify([part, o.category, o.property]);
|
|
80
|
+
if (keys.has(key))
|
|
81
|
+
throw new Error(`Duplicate override for ${s.name}/${part}/${o.property}.`);
|
|
82
|
+
keys.add(key);
|
|
83
|
+
const values = [
|
|
84
|
+
...new Set(o.values.map((v) => normalizeValue(o.property, v))),
|
|
85
|
+
].sort();
|
|
86
|
+
if (values.some((v) => ["transparent", "0px", "none", "normal", "auto"].includes(v)))
|
|
87
|
+
throw new Error(`Override for ${s.name} contains a value the scanner does not collect.`);
|
|
88
|
+
overrides.push({
|
|
89
|
+
part,
|
|
90
|
+
category: o.category,
|
|
91
|
+
property: o.property,
|
|
92
|
+
values,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
names.add(s.name);
|
|
96
|
+
selectors.add(s.selector.trim());
|
|
97
|
+
result.push({
|
|
98
|
+
name: s.name,
|
|
99
|
+
selector: s.selector.trim(),
|
|
100
|
+
...(s.variant ? { variant: s.variant } : {}),
|
|
101
|
+
...(s.minOccurrences !== undefined
|
|
102
|
+
? { minOccurrences: Number(s.minOccurrences) }
|
|
103
|
+
: {}),
|
|
104
|
+
parts,
|
|
105
|
+
overrides: overrides.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return result.sort((a, b) => a.name.localeCompare(b.name));
|
|
109
|
+
}
|
|
110
|
+
export function resolveRule(contract, element, observation) {
|
|
111
|
+
if (contract.version === 1)
|
|
112
|
+
return;
|
|
113
|
+
const membership = element.scope;
|
|
114
|
+
const definition = contract.scopes?.find((s) => s.name === membership?.name);
|
|
115
|
+
const rule = contract.rules?.find((r) => r.scope === membership?.name &&
|
|
116
|
+
r.part === membership?.part &&
|
|
117
|
+
r.category === observation.category &&
|
|
118
|
+
r.property === observation.property);
|
|
119
|
+
const context = membership
|
|
120
|
+
? {
|
|
121
|
+
scope: membership.name,
|
|
122
|
+
...(definition?.variant ? { variant: definition.variant } : {}),
|
|
123
|
+
part: membership.part,
|
|
124
|
+
}
|
|
125
|
+
: {};
|
|
126
|
+
const threshold = definition?.minOccurrences ?? Math.max(2, contract.minOccurrences);
|
|
127
|
+
const supported = rule?.tokens.filter((t) => rule.source === "override" || t.count >= threshold) ?? [];
|
|
128
|
+
if (rule &&
|
|
129
|
+
supported.length &&
|
|
130
|
+
(rule.source === "override" || !contract.partial))
|
|
131
|
+
return {
|
|
132
|
+
...context,
|
|
133
|
+
source: rule.source,
|
|
134
|
+
samples: rule.samples,
|
|
135
|
+
tokens: supported,
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
...context,
|
|
139
|
+
source: "global",
|
|
140
|
+
reason: !membership
|
|
141
|
+
? "unscoped"
|
|
142
|
+
: contract.partial
|
|
143
|
+
? "partial-baseline"
|
|
144
|
+
: !rule
|
|
145
|
+
? "missing-rule"
|
|
146
|
+
: "insufficient-support",
|
|
147
|
+
samples: rule?.samples ?? 0,
|
|
148
|
+
tokens: contract[observation.category],
|
|
149
|
+
};
|
|
150
|
+
}
|
package/dist/drift/compare.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { resolveRule } from "../contract/scopes.js";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { baselineMatcher } from "../scanner/identity.js";
|
|
4
|
+
import { findingEvidence } from "./evidence.js";
|
|
2
5
|
import { colorDistance } from "../utils/normalize.js";
|
|
3
6
|
export const labels = {
|
|
4
7
|
colors: "Unexpected color",
|
|
@@ -41,27 +44,23 @@ function equivalent(category, a, b) {
|
|
|
41
44
|
}
|
|
42
45
|
export function compare(contract, baseline, current) {
|
|
43
46
|
const issues = new Map();
|
|
44
|
-
const exceptions = new Map();
|
|
45
|
-
for (const s of baseline.snapshots)
|
|
46
|
-
for (const e of s.elements)
|
|
47
|
-
for (const o of e.observations) {
|
|
48
|
-
const key = `${s.route}\0${s.viewport.name}\0${e.selector}\0${o.property}\0${o.category}`;
|
|
49
|
-
const values = exceptions.get(key) ?? new Set();
|
|
50
|
-
values.add(o.value);
|
|
51
|
-
exceptions.set(key, values);
|
|
52
|
-
}
|
|
53
47
|
function push(issue, viewport) {
|
|
54
48
|
const key = [
|
|
55
49
|
issue.category,
|
|
56
50
|
issue.route,
|
|
57
|
-
issue.selector,
|
|
51
|
+
issue.identity ?? issue.selector,
|
|
58
52
|
issue.current,
|
|
59
53
|
issue.title,
|
|
60
|
-
]
|
|
54
|
+
]
|
|
55
|
+
.concat(issue.rule ? [issue.property, JSON.stringify(issue.rule)] : [])
|
|
56
|
+
.join("\0");
|
|
61
57
|
const previous = issues.get(key);
|
|
62
58
|
if (previous) {
|
|
63
59
|
if (!previous.viewports.includes(viewport))
|
|
64
60
|
previous.viewports.push(viewport);
|
|
61
|
+
for (const evidence of issue.evidence ?? [])
|
|
62
|
+
if (!previous.evidence?.some((e) => e.viewport.name === evidence.viewport.name))
|
|
63
|
+
previous.evidence?.push(evidence);
|
|
65
64
|
if (!previous.property.split(", ").includes(issue.property))
|
|
66
65
|
previous.property += `, ${issue.property}`;
|
|
67
66
|
}
|
|
@@ -73,26 +72,46 @@ export function compare(contract, baseline, current) {
|
|
|
73
72
|
});
|
|
74
73
|
}
|
|
75
74
|
for (const snapshot of current.snapshots) {
|
|
76
|
-
|
|
75
|
+
const previousSnapshot = baseline.snapshots.find((s) => s.route === snapshot.route &&
|
|
76
|
+
s.viewport.name === snapshot.viewport.name &&
|
|
77
|
+
s.viewport.width === snapshot.viewport.width &&
|
|
78
|
+
s.viewport.height === snapshot.viewport.height);
|
|
79
|
+
const match = baselineMatcher(previousSnapshot?.elements ?? []);
|
|
80
|
+
for (const element of snapshot.elements) {
|
|
81
|
+
const prior = match(element);
|
|
77
82
|
for (const o of element.observations) {
|
|
78
|
-
const
|
|
83
|
+
const rule = resolveRule(contract, element, o);
|
|
84
|
+
const expected = (rule?.tokens ?? contract[o.category]).map((t) => t.value);
|
|
79
85
|
if (expected.some((v) => equivalent(o.category, v, o.value)))
|
|
80
86
|
continue;
|
|
81
|
-
|
|
82
|
-
|
|
87
|
+
if (rule?.source !== "override" &&
|
|
88
|
+
(contract.version === 1 ||
|
|
89
|
+
(prior?.scope?.name === element.scope?.name &&
|
|
90
|
+
prior?.scope?.part === element.scope?.part)) &&
|
|
91
|
+
prior?.observations.some((p) => p.category === o.category &&
|
|
92
|
+
p.property === o.property &&
|
|
93
|
+
equivalent(o.category, p.value, o.value)))
|
|
83
94
|
continue;
|
|
84
95
|
push({
|
|
96
|
+
...(rule ? { rule } : {}),
|
|
85
97
|
category: o.category,
|
|
86
98
|
severity: severityFor(o.category, o.value, expected),
|
|
87
99
|
title: labels[o.category],
|
|
88
100
|
selector: element.selector,
|
|
101
|
+
identity: element.identity ?? `selector:${element.selector}`,
|
|
102
|
+
identityConfidence: element.identityConfidence ?? "low",
|
|
103
|
+
evidence: [
|
|
104
|
+
findingEvidence(snapshot, previousSnapshot, element, prior),
|
|
105
|
+
],
|
|
89
106
|
property: o.property,
|
|
90
107
|
current: o.value,
|
|
91
108
|
expected,
|
|
92
109
|
route: snapshot.route,
|
|
93
110
|
}, snapshot.viewport.name);
|
|
94
111
|
}
|
|
95
|
-
|
|
112
|
+
}
|
|
113
|
+
for (const problem of snapshot.layout) {
|
|
114
|
+
const element = snapshot.elements.find((e) => e.selector === problem.selector);
|
|
96
115
|
push({
|
|
97
116
|
category: "layout",
|
|
98
117
|
severity: problem.severity,
|
|
@@ -102,18 +121,33 @@ export function compare(contract, baseline, current) {
|
|
|
102
121
|
? "Clipped content"
|
|
103
122
|
: "Element outside viewport",
|
|
104
123
|
selector: problem.selector,
|
|
124
|
+
identity: element?.identity ?? `selector:${problem.selector}`,
|
|
125
|
+
identityConfidence: element?.identityConfidence ?? "low",
|
|
126
|
+
evidence: [
|
|
127
|
+
findingEvidence(snapshot, previousSnapshot, element, element ? match(element) : undefined),
|
|
128
|
+
],
|
|
105
129
|
property: problem.kind,
|
|
106
130
|
current: problem.value,
|
|
107
131
|
expected: ["Content fits within the viewport"],
|
|
108
132
|
route: snapshot.route,
|
|
109
133
|
}, snapshot.viewport.name);
|
|
134
|
+
}
|
|
110
135
|
}
|
|
111
136
|
const rank = { high: 0, medium: 1, low: 2 };
|
|
137
|
+
if (contract.version === 2)
|
|
138
|
+
for (const issue of issues.values()) {
|
|
139
|
+
issue.viewports.sort();
|
|
140
|
+
issue.evidence?.sort((a, b) => a.viewport.name.localeCompare(b.viewport.name));
|
|
141
|
+
}
|
|
112
142
|
return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
|
|
113
143
|
a.category.localeCompare(b.category) ||
|
|
114
144
|
a.route.localeCompare(b.route) ||
|
|
115
|
-
a.selector.localeCompare(b.selector) ||
|
|
116
|
-
a.current.localeCompare(b.current)
|
|
145
|
+
(a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
|
|
146
|
+
a.current.localeCompare(b.current) ||
|
|
147
|
+
(contract.version === 2
|
|
148
|
+
? a.property.localeCompare(b.property) ||
|
|
149
|
+
JSON.stringify(a.rule ?? null).localeCompare(JSON.stringify(b.rule ?? null))
|
|
150
|
+
: 0));
|
|
117
151
|
}
|
|
118
152
|
export function consistencyScore(issues) {
|
|
119
153
|
return Math.max(0, 100 -
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Bounds, ElementRecord, Evidence, Snapshot, Viewport } from "../types.js";
|
|
2
|
+
/** Keep only the visible part; below-fold, invalid and absent bounds have no overlay. */
|
|
3
|
+
export declare function visibleBounds(bounds: Bounds | undefined, viewport: Viewport): Bounds | undefined;
|
|
4
|
+
export declare function findingEvidence(current: Snapshot, baseline?: Snapshot, element?: ElementRecord, prior?: ElementRecord): Evidence;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Keep only the visible part; below-fold, invalid and absent bounds have no overlay. */
|
|
2
|
+
export function visibleBounds(bounds, viewport) {
|
|
3
|
+
if (!bounds ||
|
|
4
|
+
![
|
|
5
|
+
bounds.x,
|
|
6
|
+
bounds.y,
|
|
7
|
+
bounds.width,
|
|
8
|
+
bounds.height,
|
|
9
|
+
viewport.width,
|
|
10
|
+
viewport.height,
|
|
11
|
+
].every(Number.isFinite) ||
|
|
12
|
+
bounds.width <= 0 ||
|
|
13
|
+
bounds.height <= 0 ||
|
|
14
|
+
viewport.width <= 0 ||
|
|
15
|
+
viewport.height <= 0)
|
|
16
|
+
return;
|
|
17
|
+
const x = Math.max(0, bounds.x), y = Math.max(0, bounds.y);
|
|
18
|
+
const right = Math.min(viewport.width, bounds.x + bounds.width);
|
|
19
|
+
const bottom = Math.min(viewport.height, bounds.y + bounds.height);
|
|
20
|
+
if (right <= x || bottom <= y)
|
|
21
|
+
return;
|
|
22
|
+
return { x, y, width: right - x, height: bottom - y };
|
|
23
|
+
}
|
|
24
|
+
export function findingEvidence(current, baseline, element, prior) {
|
|
25
|
+
return {
|
|
26
|
+
viewport: current.viewport,
|
|
27
|
+
current: {
|
|
28
|
+
screenshot: current.screenshot,
|
|
29
|
+
...(element?.bounds ? { bounds: element.bounds } : {}),
|
|
30
|
+
},
|
|
31
|
+
// A baseline capture is useful even when no exact element match is available.
|
|
32
|
+
...(baseline
|
|
33
|
+
? {
|
|
34
|
+
baseline: {
|
|
35
|
+
screenshot: baseline.screenshot,
|
|
36
|
+
...(prior?.bounds ? { bounds: prior.bounds } : {}),
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
: {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CollectedElement } from "./identity.js";
|
|
2
|
+
import type { LayoutProblem, ScopeDefinition } from "../types.js";
|
|
2
3
|
/** Pure DOM reader, serialized by Playwright into the page. */
|
|
3
|
-
export declare function collect({ maxElements, ignore, }: {
|
|
4
|
+
export declare function collect({ maxElements, ignore, scopes, }: {
|
|
4
5
|
maxElements: number;
|
|
5
6
|
ignore: string[];
|
|
7
|
+
scopes?: ScopeDefinition[];
|
|
6
8
|
}): {
|
|
7
|
-
elements:
|
|
9
|
+
elements: CollectedElement[];
|
|
8
10
|
layout: LayoutProblem[];
|
|
9
11
|
truncated: boolean;
|
|
10
12
|
};
|
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 = [], }) {
|
|
3
3
|
for (const selector of ignore) {
|
|
4
4
|
try {
|
|
5
5
|
document.querySelector(selector);
|
|
@@ -8,6 +8,49 @@ export function collect({ maxElements, ignore, }) {
|
|
|
8
8
|
throw new Error(`Invalid ignore selector: ${selector}`);
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
// Validate declarations even when the page contains no matching elements.
|
|
12
|
+
for (const scope of scopes) {
|
|
13
|
+
for (const selector of [
|
|
14
|
+
scope.selector,
|
|
15
|
+
...Object.values(scope.parts ?? {}),
|
|
16
|
+
]) {
|
|
17
|
+
try {
|
|
18
|
+
document.querySelector(selector);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new Error(`Invalid selector in scope ${scope.name}: ${selector}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function scopeFor(el) {
|
|
26
|
+
for (let root = el; root; root = root.parentElement) {
|
|
27
|
+
const matches = scopes.filter((s) => root.matches(s.selector));
|
|
28
|
+
if (matches.length > 1)
|
|
29
|
+
throw new Error(`Ambiguous scopes on ${selectorFor(root)}: ${matches
|
|
30
|
+
.map((s) => s.name)
|
|
31
|
+
.sort()
|
|
32
|
+
.join(", ")}`);
|
|
33
|
+
if (!matches.length)
|
|
34
|
+
continue;
|
|
35
|
+
const scope = matches[0];
|
|
36
|
+
for (let node = el; node; node = node.parentElement) {
|
|
37
|
+
const parts = Object.entries(scope.parts ?? {}).filter(([, selector]) => node.matches(selector));
|
|
38
|
+
if (parts.length > 1)
|
|
39
|
+
throw new Error(`Ambiguous parts in ${scope.name} on ${selectorFor(node)}.`);
|
|
40
|
+
if (parts.length || node === root) {
|
|
41
|
+
// Unnamed descendants do not inherit container padding/radius conventions.
|
|
42
|
+
if (!parts.length && el !== root)
|
|
43
|
+
return;
|
|
44
|
+
return {
|
|
45
|
+
name: scope.name,
|
|
46
|
+
...(scope.variant ? { variant: scope.variant } : {}),
|
|
47
|
+
part: parts[0]?.[0] ?? "root",
|
|
48
|
+
instance: selectorFor(root),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
11
54
|
const excluded = ignore.length ? ignore.join(",") : null;
|
|
12
55
|
function visible(el) {
|
|
13
56
|
if (excluded && el.closest(excluded))
|
|
@@ -48,6 +91,9 @@ export function collect({ maxElements, ignore, }) {
|
|
|
48
91
|
for (const name of classes)
|
|
49
92
|
if (document.querySelectorAll(`.${escapeCss(name)}`).length === 1)
|
|
50
93
|
return `.${escapeCss(name)}`;
|
|
94
|
+
return structureFor(el);
|
|
95
|
+
}
|
|
96
|
+
function structureFor(el) {
|
|
51
97
|
const parts = [];
|
|
52
98
|
let node = el;
|
|
53
99
|
while (node && node !== document.body) {
|
|
@@ -140,7 +186,37 @@ export function collect({ maxElements, ignore, }) {
|
|
|
140
186
|
add("shadows", "box-shadow");
|
|
141
187
|
if (control)
|
|
142
188
|
add("elementHeights", "height", `${rect.height}px`);
|
|
143
|
-
|
|
189
|
+
const attributes = Object.fromEntries([
|
|
190
|
+
"id",
|
|
191
|
+
"data-testid",
|
|
192
|
+
"data-test",
|
|
193
|
+
"data-cy",
|
|
194
|
+
"role",
|
|
195
|
+
"aria-label",
|
|
196
|
+
"name",
|
|
197
|
+
"alt",
|
|
198
|
+
"href",
|
|
199
|
+
].map((name) => [name, el.getAttribute(name) ?? ""]));
|
|
200
|
+
const labelledBy = el.getAttribute("aria-labelledby");
|
|
201
|
+
if (labelledBy)
|
|
202
|
+
attributes["aria-label"] = labelledBy
|
|
203
|
+
.split(/\s+/)
|
|
204
|
+
.map((id) => document.getElementById(id)?.textContent ?? "")
|
|
205
|
+
.join(" ");
|
|
206
|
+
const scope = scopeFor(el);
|
|
207
|
+
elements.push({
|
|
208
|
+
...(scope ? { scope } : {}),
|
|
209
|
+
selector,
|
|
210
|
+
tag: el.tagName.toLowerCase(),
|
|
211
|
+
observations,
|
|
212
|
+
bounds: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
213
|
+
signals: {
|
|
214
|
+
attributes,
|
|
215
|
+
text: el.textContent ?? "",
|
|
216
|
+
classes: [...el.classList],
|
|
217
|
+
structure: structureFor(el),
|
|
218
|
+
},
|
|
219
|
+
});
|
|
144
220
|
const outside = Math.max(0, rect.right - innerWidth, -rect.left);
|
|
145
221
|
if (outside > 4 &&
|
|
146
222
|
!scrollContainer(el) &&
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ElementRecord, IdentityConfidence } from "../types.js";
|
|
2
|
+
export interface IdentitySignals {
|
|
3
|
+
attributes: Record<string, string>;
|
|
4
|
+
text: string;
|
|
5
|
+
classes: string[];
|
|
6
|
+
structure?: string;
|
|
7
|
+
}
|
|
8
|
+
export type CollectedElement = ElementRecord & {
|
|
9
|
+
signals: IdentitySignals;
|
|
10
|
+
};
|
|
11
|
+
export declare function identityCandidates(element: CollectedElement): [string, IdentityConfidence][];
|
|
12
|
+
/** Only unique signals identify an element; ambiguous matches use the selector. */
|
|
13
|
+
export declare function assignIdentities(elements: CollectedElement[]): ElementRecord[];
|
|
14
|
+
/** No selector rescue when two new records have different semantic identities. */
|
|
15
|
+
export declare function baselineMatcher(elements: ElementRecord[]): (current: ElementRecord) => ElementRecord | undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
const concise = (value) => {
|
|
2
|
+
const text = value.replace(/\s+/g, " ").trim();
|
|
3
|
+
return text.length <= 80 ? text : "";
|
|
4
|
+
};
|
|
5
|
+
// Obvious counters, framework IDs and CSS module hashes are not semantic anchors.
|
|
6
|
+
const stable = (value) => !!value &&
|
|
7
|
+
value.length <= 80 &&
|
|
8
|
+
!/\d{3,}|[a-f\d]{8,}|(^|[-_:])(css|sc|radix|react|uid)[-_:]|__|:[rR]|[a-z][A-Z0-9]{5,}/.test(value);
|
|
9
|
+
export function identityCandidates(element) {
|
|
10
|
+
const { attributes: a, classes, text } = element.signals;
|
|
11
|
+
const result = [];
|
|
12
|
+
const add = (kind, value, confidence) => {
|
|
13
|
+
if (value)
|
|
14
|
+
result.push([JSON.stringify([kind, value]), confidence]);
|
|
15
|
+
};
|
|
16
|
+
if (stable(a.id ?? ""))
|
|
17
|
+
add("id", a.id, "high");
|
|
18
|
+
for (const attr of ["data-testid", "data-test", "data-cy"])
|
|
19
|
+
if (stable(a[attr] ?? ""))
|
|
20
|
+
add(attr, a[attr], "high");
|
|
21
|
+
const role = a.role ||
|
|
22
|
+
{
|
|
23
|
+
button: "button",
|
|
24
|
+
a: "link",
|
|
25
|
+
input: "input",
|
|
26
|
+
select: "select",
|
|
27
|
+
textarea: "textbox",
|
|
28
|
+
img: "img",
|
|
29
|
+
}[element.tag] ||
|
|
30
|
+
element.tag;
|
|
31
|
+
for (const attr of ["aria-label", "name", "alt"])
|
|
32
|
+
add(`${role}:${attr}`, concise(a[attr] ?? ""), "medium");
|
|
33
|
+
if (element.tag === "a" && a.href && !/[?#]/.test(a.href))
|
|
34
|
+
add("link:href", concise(a.href), "medium");
|
|
35
|
+
if (/^(button|a|label|h[1-6]|option)$/.test(element.tag)) {
|
|
36
|
+
const label = concise(text);
|
|
37
|
+
if (!/\d/.test(label))
|
|
38
|
+
add(`${role}:text`, label, "medium");
|
|
39
|
+
}
|
|
40
|
+
for (const name of [...classes].sort())
|
|
41
|
+
if (stable(name) && /^[a-zA-Z][\w-]*$/.test(name))
|
|
42
|
+
add(`${element.tag}:class`, name, "medium");
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/** Only unique signals identify an element; ambiguous matches use the selector. */
|
|
46
|
+
export function assignIdentities(elements) {
|
|
47
|
+
const candidates = elements.map(identityCandidates);
|
|
48
|
+
const counts = new Map();
|
|
49
|
+
for (const list of candidates)
|
|
50
|
+
for (const key of new Set(list.map(([key]) => key)))
|
|
51
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
52
|
+
return elements.map(({ signals: _signals, ...element }, index) => {
|
|
53
|
+
const unique = candidates[index].find(([key]) => counts.get(key) === 1);
|
|
54
|
+
return {
|
|
55
|
+
...element,
|
|
56
|
+
identity: unique?.[0] ?? `structure:${_signals.structure ?? element.selector}`,
|
|
57
|
+
identityConfidence: unique?.[1] ?? "low",
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** No selector rescue when two new records have different semantic identities. */
|
|
62
|
+
export function baselineMatcher(elements) {
|
|
63
|
+
const identities = new Map();
|
|
64
|
+
const selectors = new Map();
|
|
65
|
+
for (const element of elements) {
|
|
66
|
+
if (element.identity)
|
|
67
|
+
identities.set(element.identity, [
|
|
68
|
+
...(identities.get(element.identity) ?? []),
|
|
69
|
+
element,
|
|
70
|
+
]);
|
|
71
|
+
selectors.set(element.selector, [
|
|
72
|
+
...(selectors.get(element.selector) ?? []),
|
|
73
|
+
element,
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
return (current) => {
|
|
77
|
+
const semantic = current.identity
|
|
78
|
+
? identities.get(current.identity)
|
|
79
|
+
: undefined;
|
|
80
|
+
if (semantic?.length === 1)
|
|
81
|
+
return semantic[0];
|
|
82
|
+
const fallback = selectors.get(current.selector);
|
|
83
|
+
if (fallback?.length === 1 && (!current.identity || !fallback[0].identity))
|
|
84
|
+
return fallback[0];
|
|
85
|
+
};
|
|
86
|
+
}
|
package/dist/scanner/scan.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { openBrowser } from "./browser.js";
|
|
2
|
+
import { assignIdentities } from "./identity.js";
|
|
2
3
|
import { collect } from "./collect.js";
|
|
3
4
|
import { mkdir } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -47,6 +48,7 @@ export async function scan(config, directory, phase) {
|
|
|
47
48
|
const collected = await page.evaluate(collect, {
|
|
48
49
|
maxElements: config.maxElements,
|
|
49
50
|
ignore: config.ignore,
|
|
51
|
+
scopes: config.scopes,
|
|
50
52
|
});
|
|
51
53
|
if (!collected.elements.length)
|
|
52
54
|
throw new Error(`No visible elements found at ${url} (${viewport.name}). Check your route and ignore selectors.`);
|
|
@@ -71,7 +73,13 @@ export async function scan(config, directory, phase) {
|
|
|
71
73
|
fullPage: false,
|
|
72
74
|
animations: "disabled",
|
|
73
75
|
});
|
|
74
|
-
result.snapshots.push({
|
|
76
|
+
result.snapshots.push({
|
|
77
|
+
route,
|
|
78
|
+
viewport,
|
|
79
|
+
...collected,
|
|
80
|
+
elements: assignIdentities(collected.elements),
|
|
81
|
+
screenshot,
|
|
82
|
+
});
|
|
75
83
|
await context.close();
|
|
76
84
|
}
|
|
77
85
|
return result;
|
package/dist/types.d.ts
CHANGED
|
@@ -14,13 +14,73 @@ export interface Config {
|
|
|
14
14
|
maxElements: number;
|
|
15
15
|
settleMs: number;
|
|
16
16
|
ignore: string[];
|
|
17
|
+
scopes?: ScopeDefinition[];
|
|
18
|
+
}
|
|
19
|
+
export interface ScopeDefinition {
|
|
20
|
+
name: string;
|
|
21
|
+
selector: string;
|
|
22
|
+
variant?: string;
|
|
23
|
+
minOccurrences?: number;
|
|
24
|
+
parts?: Record<string, string>;
|
|
25
|
+
overrides?: {
|
|
26
|
+
part?: string;
|
|
27
|
+
category: Category;
|
|
28
|
+
property: string;
|
|
29
|
+
values: string[];
|
|
30
|
+
}[];
|
|
31
|
+
}
|
|
32
|
+
export interface ScopeMembership {
|
|
33
|
+
name: string;
|
|
34
|
+
variant?: string;
|
|
35
|
+
part: string;
|
|
36
|
+
instance: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ScopedRule {
|
|
39
|
+
scope: string;
|
|
40
|
+
part: string;
|
|
41
|
+
category: Category;
|
|
42
|
+
property: string;
|
|
43
|
+
samples: number;
|
|
44
|
+
tokens: Token[];
|
|
45
|
+
source: "scope" | "override";
|
|
46
|
+
}
|
|
47
|
+
export interface RuleProvenance {
|
|
48
|
+
scope?: string;
|
|
49
|
+
variant?: string;
|
|
50
|
+
part?: string;
|
|
51
|
+
source: "scope" | "override" | "global";
|
|
52
|
+
reason?: "unscoped" | "missing-rule" | "insufficient-support" | "partial-baseline";
|
|
53
|
+
samples: number;
|
|
54
|
+
tokens: Token[];
|
|
17
55
|
}
|
|
18
56
|
export interface Observation {
|
|
19
57
|
category: Category;
|
|
20
58
|
property: string;
|
|
21
59
|
value: string;
|
|
22
60
|
}
|
|
61
|
+
export type IdentityConfidence = "high" | "medium" | "low";
|
|
62
|
+
export interface Bounds {
|
|
63
|
+
x: number;
|
|
64
|
+
y: number;
|
|
65
|
+
width: number;
|
|
66
|
+
height: number;
|
|
67
|
+
}
|
|
68
|
+
export interface Evidence {
|
|
69
|
+
viewport: Viewport;
|
|
70
|
+
current: {
|
|
71
|
+
screenshot: string;
|
|
72
|
+
bounds?: Bounds;
|
|
73
|
+
};
|
|
74
|
+
baseline?: {
|
|
75
|
+
screenshot: string;
|
|
76
|
+
bounds?: Bounds;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
23
79
|
export interface ElementRecord {
|
|
80
|
+
scope?: ScopeMembership;
|
|
81
|
+
identity?: string;
|
|
82
|
+
identityConfidence?: IdentityConfidence;
|
|
83
|
+
bounds?: Bounds;
|
|
24
84
|
selector: string;
|
|
25
85
|
tag: string;
|
|
26
86
|
observations: Observation[];
|
|
@@ -50,11 +110,18 @@ export interface Token {
|
|
|
50
110
|
count: number;
|
|
51
111
|
}
|
|
52
112
|
export type Contract = {
|
|
53
|
-
version: 1;
|
|
113
|
+
version: 1 | 2;
|
|
114
|
+
scopes?: ScopeDefinition[];
|
|
115
|
+
rules?: ScopedRule[];
|
|
116
|
+
partial?: boolean;
|
|
54
117
|
createdAt: string;
|
|
55
118
|
minOccurrences: number;
|
|
56
119
|
} & Record<Category, Token[]>;
|
|
57
120
|
export interface Issue {
|
|
121
|
+
rule?: RuleProvenance;
|
|
122
|
+
identity?: string;
|
|
123
|
+
identityConfidence?: IdentityConfidence;
|
|
124
|
+
evidence?: Evidence[];
|
|
58
125
|
id: string;
|
|
59
126
|
category: Category | "layout";
|
|
60
127
|
severity: Severity;
|