tether-ui 0.3.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 +83 -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 +24 -5
- package/dist/cli/index.js +25 -21
- package/dist/cli/json.d.ts +60 -2
- package/dist/cli/json.js +66 -41
- package/dist/drift/compare.d.ts +4 -1
- package/dist/drift/compare.js +76 -30
- package/dist/drift/evidence.js +1 -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 +59 -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 +1 -1
- 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-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 → TBRjO9Fdl2ToZdlLQ7gIf}/_buildManifest.js +0 -0
- /package/report/out/_next/static/{l8qlXIaCzWbRx2ZWOTp_i → TBRjO9Fdl2ToZdlLQ7gIf}/_ssgManifest.js +0 -0
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { persistedConfig, safeUrl } from "../utils/scenarios.js";
|
|
2
3
|
import { readContract } from "../contract/read.js";
|
|
3
|
-
import {
|
|
4
|
+
import { validateCoverage } from "../utils/coverage.js";
|
|
5
|
+
import { approvalCommands } from "./approvals.js";
|
|
6
|
+
import { readApprovals } from "../approvals/storage.js";
|
|
7
|
+
import { applyApprovals } from "../approvals/match.js";
|
|
4
8
|
import { Command } from "commander";
|
|
5
9
|
import { join, resolve } from "node:path";
|
|
6
10
|
import { createInterface } from "node:readline/promises";
|
|
@@ -18,21 +22,26 @@ import { categories, } from "../types.js";
|
|
|
18
22
|
const program = new Command()
|
|
19
23
|
.name("tether")
|
|
20
24
|
.description("Visual linting for AI-coded interfaces.")
|
|
21
|
-
.version("0.
|
|
25
|
+
.version("0.4.0");
|
|
22
26
|
program.option("--cwd <directory>", "Project directory", process.cwd());
|
|
23
27
|
const cwd = () => resolve(program.opts().cwd);
|
|
24
28
|
const directory = () => join(cwd(), ".tether");
|
|
25
|
-
function reportFor(contract, baseline, current) {
|
|
26
|
-
const
|
|
29
|
+
function reportFor(contract, baseline, current, file) {
|
|
30
|
+
const result = file
|
|
31
|
+
? applyApprovals(contract, baseline, current, file)
|
|
32
|
+
: { issues: compare(contract, baseline, current) };
|
|
33
|
+
const { issues } = result;
|
|
34
|
+
const approvals = "approvals" in result ? result.approvals : undefined;
|
|
27
35
|
return {
|
|
28
|
-
version: 1,
|
|
36
|
+
version: approvals ? 2 : 1,
|
|
37
|
+
...(approvals ? { approvals } : {}),
|
|
29
38
|
baselineAt: baseline.createdAt,
|
|
30
39
|
scan: current,
|
|
31
40
|
contract,
|
|
32
41
|
issues,
|
|
33
42
|
score: consistencyScore(issues),
|
|
34
43
|
elementCount: current.snapshots.reduce((n, s) => n + s.elements.length, 0),
|
|
35
|
-
agentMarkdown: agentMarkdown(issues),
|
|
44
|
+
agentMarkdown: agentMarkdown(issues, approvals),
|
|
36
45
|
};
|
|
37
46
|
}
|
|
38
47
|
function warnTruncated(report) {
|
|
@@ -64,20 +73,21 @@ program
|
|
|
64
73
|
prompt.close();
|
|
65
74
|
}
|
|
66
75
|
}
|
|
76
|
+
const approvalFile = await readApprovals(cwd());
|
|
67
77
|
const config = await loadConfig(cwd(), options.url, options.config);
|
|
68
|
-
console.log(`\ntether\n\nScanning ${config.url}…`);
|
|
78
|
+
console.log(`\ntether\n\nScanning ${safeUrl(config.url)}…`);
|
|
69
79
|
const baseline = await scan(config, directory(), `baseline-${randomUUID().slice(0, 8)}`);
|
|
70
80
|
const contract = inferContract(baseline, config.minOccurrences, config.scopes);
|
|
71
81
|
// Save the exact scan configuration in the baseline so subsequent checks match it.
|
|
72
82
|
await writeJson(join(directory(), "contract.json"), contract);
|
|
73
83
|
await writeJson(join(directory(), "baseline.json"), {
|
|
74
84
|
...baseline,
|
|
75
|
-
config,
|
|
85
|
+
config: persistedConfig(config),
|
|
76
86
|
});
|
|
77
|
-
const report = reportFor(contract, baseline, baseline);
|
|
87
|
+
const report = reportFor(contract, baseline, baseline, approvalFile);
|
|
78
88
|
await writeJson(join(directory(), "report.json"), report);
|
|
79
89
|
if (!(await exists(join(cwd(), "tether.config.ts"))) && !options.config)
|
|
80
|
-
await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(config, null, 2)};\n`);
|
|
90
|
+
await writeFile(join(cwd(), "tether.config.ts"), `export default ${JSON.stringify(persistedConfig(config), null, 2).replace(JSON.stringify(safeUrl(config.url)), safeUrl(config.url) !== config.url ? "process.env.TETHER_URL" : JSON.stringify(config.url))};\n`);
|
|
81
91
|
console.log(`\n✓ Baseline saved\n✓ ${categories.reduce((n, c) => n + contract[c].length, 0)} tokens in your visual contract\n✓ ${report.elementCount} elements inspected across ${baseline.snapshots.length} snapshots\n\nRun: tether check\n`);
|
|
82
92
|
if (report.issues.length)
|
|
83
93
|
console.log(`Note: ${report.issues.length} existing visual deviations are visible in tether report.`);
|
|
@@ -103,24 +113,17 @@ program
|
|
|
103
113
|
categories.some((c) => !Array.isArray(contract[c]) ||
|
|
104
114
|
contract[c].some((t) => typeof t.value !== "string")))
|
|
105
115
|
throw new Error("Unsupported or invalid baseline / contract. Run tether init --force to recreate it.");
|
|
116
|
+
const approvalFile = await readApprovals(cwd());
|
|
106
117
|
const config = await loadConfig(cwd(), options.url, options.config);
|
|
107
118
|
if (!options.url &&
|
|
108
119
|
!options.config &&
|
|
109
120
|
!(await exists(join(cwd(), "tether.config.ts"))))
|
|
110
121
|
config.url = baseline.url;
|
|
111
|
-
|
|
112
|
-
JSON.stringify(validateScopes(contract.scopes ?? [])))
|
|
113
|
-
throw new Error("Component scopes or overrides changed since the baseline. Run tether init --force to establish the new contract.");
|
|
114
|
-
const coverage = (s) => `${s.route}|${s.viewport.name}|${s.viewport.width}|${s.viewport.height}`;
|
|
115
|
-
const baselineCoverage = new Set(baseline.snapshots.map(coverage));
|
|
116
|
-
const requested = config.routes.flatMap((route) => config.viewports.map((viewport) => coverage({ route, viewport })));
|
|
117
|
-
if (requested.length !== baselineCoverage.size ||
|
|
118
|
-
requested.some((k) => !baselineCoverage.has(k)))
|
|
119
|
-
throw new Error("Routes or viewports changed since the baseline. Run tether init --force to establish the new coverage.");
|
|
122
|
+
validateCoverage(config, contract, baseline);
|
|
120
123
|
if (!options.agent && !options.json)
|
|
121
|
-
console.log(`\nScanning ${config.url}…\n`);
|
|
124
|
+
console.log(`\nScanning ${safeUrl(config.url)}…\n`);
|
|
122
125
|
const current = await scan(config, directory(), `scan-${randomUUID().slice(0, 8)}`);
|
|
123
|
-
const report = reportFor(contract, baseline, current);
|
|
126
|
+
const report = reportFor(contract, baseline, current, approvalFile);
|
|
124
127
|
await writeJson(join(directory(), "report.json"), report);
|
|
125
128
|
console.log(options.json
|
|
126
129
|
? JSON.stringify(jsonReport(report, baseline), null, 2)
|
|
@@ -142,6 +145,7 @@ program
|
|
|
142
145
|
throw new Error("Port must be 0 or an integer between 1024 and 65535.");
|
|
143
146
|
await serveReport(directory(), port, options.open);
|
|
144
147
|
});
|
|
148
|
+
approvalCommands(program, cwd);
|
|
145
149
|
program.parseAsync().catch((error) => {
|
|
146
150
|
console.error(`\ntether: ${error.message}\n`);
|
|
147
151
|
process.exitCode = 2;
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { type Report, type Scan } from "../types.js";
|
|
|
2
2
|
/** Screenshot references are local JSON pointers, not random scan directory names. */
|
|
3
3
|
export declare function jsonReport(report: Report, baseline: Scan): {
|
|
4
4
|
schema: string;
|
|
5
|
-
version:
|
|
5
|
+
version: 1;
|
|
6
6
|
summary: {
|
|
7
7
|
total: number;
|
|
8
8
|
severity: {
|
|
@@ -13,6 +13,23 @@ export declare function jsonReport(report: Report, baseline: Scan): {
|
|
|
13
13
|
};
|
|
14
14
|
};
|
|
15
15
|
findings: {
|
|
16
|
+
viewports: string[];
|
|
17
|
+
property: string;
|
|
18
|
+
current: string;
|
|
19
|
+
expected: string[];
|
|
20
|
+
evidence: {
|
|
21
|
+
baseline?: {
|
|
22
|
+
bounds?: import("../types.js").Bounds | undefined;
|
|
23
|
+
screenshotRef: string | null;
|
|
24
|
+
} | undefined;
|
|
25
|
+
current: {
|
|
26
|
+
bounds?: import("../types.js").Bounds | undefined;
|
|
27
|
+
screenshotRef: string | null;
|
|
28
|
+
};
|
|
29
|
+
state?: string | undefined;
|
|
30
|
+
viewport: import("../types.js").Viewport;
|
|
31
|
+
}[];
|
|
32
|
+
state?: string | undefined;
|
|
16
33
|
identity: string;
|
|
17
34
|
identityConfidence: import("../types.js").IdentityConfidence;
|
|
18
35
|
selector: string;
|
|
@@ -20,6 +37,37 @@ export declare function jsonReport(report: Report, baseline: Scan): {
|
|
|
20
37
|
category: "colors" | "fontFamilies" | "fontSizes" | "fontWeights" | "lineHeights" | "spacing" | "radii" | "shadows" | "borders" | "elementHeights" | "layout";
|
|
21
38
|
severity: import("../types.js").Severity;
|
|
22
39
|
route: string;
|
|
40
|
+
rule?: import("../types.js").RuleProvenance | undefined;
|
|
41
|
+
id: string;
|
|
42
|
+
}[];
|
|
43
|
+
staleApprovals?: undefined;
|
|
44
|
+
} | {
|
|
45
|
+
schema: string;
|
|
46
|
+
version: 2;
|
|
47
|
+
summary: {
|
|
48
|
+
total: number;
|
|
49
|
+
active: {
|
|
50
|
+
total: number;
|
|
51
|
+
severity: {
|
|
52
|
+
[k: string]: number;
|
|
53
|
+
};
|
|
54
|
+
category: {
|
|
55
|
+
[k: string]: number;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
approved: {
|
|
59
|
+
total: number;
|
|
60
|
+
severity: {
|
|
61
|
+
[k: string]: number;
|
|
62
|
+
};
|
|
63
|
+
category: {
|
|
64
|
+
[k: string]: number;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
staleApprovals: number;
|
|
68
|
+
};
|
|
69
|
+
findings: {
|
|
70
|
+
status: string;
|
|
23
71
|
viewports: string[];
|
|
24
72
|
property: string;
|
|
25
73
|
current: string;
|
|
@@ -29,13 +77,23 @@ export declare function jsonReport(report: Report, baseline: Scan): {
|
|
|
29
77
|
bounds?: import("../types.js").Bounds | undefined;
|
|
30
78
|
screenshotRef: string | null;
|
|
31
79
|
} | undefined;
|
|
32
|
-
viewport: import("../types.js").Viewport;
|
|
33
80
|
current: {
|
|
34
81
|
bounds?: import("../types.js").Bounds | undefined;
|
|
35
82
|
screenshotRef: string | null;
|
|
36
83
|
};
|
|
84
|
+
state?: string | undefined;
|
|
85
|
+
viewport: import("../types.js").Viewport;
|
|
37
86
|
}[];
|
|
87
|
+
state?: string | undefined;
|
|
88
|
+
identity: string;
|
|
89
|
+
identityConfidence: import("../types.js").IdentityConfidence;
|
|
90
|
+
selector: string;
|
|
91
|
+
title: string;
|
|
92
|
+
category: "colors" | "fontFamilies" | "fontSizes" | "fontWeights" | "lineHeights" | "spacing" | "radii" | "shadows" | "borders" | "elementHeights" | "layout";
|
|
93
|
+
severity: import("../types.js").Severity;
|
|
94
|
+
route: string;
|
|
38
95
|
rule?: import("../types.js").RuleProvenance | undefined;
|
|
39
96
|
id: string;
|
|
40
97
|
}[];
|
|
98
|
+
staleApprovals: import("../types.js").Approval[];
|
|
41
99
|
};
|
package/dist/cli/json.js
CHANGED
|
@@ -9,51 +9,76 @@ export function jsonReport(report, baseline) {
|
|
|
9
9
|
(file === "report.json" ? "scan/" : "") +
|
|
10
10
|
`snapshots/${index}/screenshot`;
|
|
11
11
|
};
|
|
12
|
+
const finding = (i) => ({
|
|
13
|
+
id: i.id,
|
|
14
|
+
...(i.rule ? { rule: i.rule } : {}),
|
|
15
|
+
identity: i.identity ?? `selector:${i.selector}`,
|
|
16
|
+
identityConfidence: i.identityConfidence ?? "low",
|
|
17
|
+
selector: i.selector,
|
|
18
|
+
title: i.title,
|
|
19
|
+
category: i.category,
|
|
20
|
+
severity: i.severity,
|
|
21
|
+
route: i.route,
|
|
22
|
+
...(i.state ? { state: i.state } : {}),
|
|
23
|
+
viewports: [...i.viewports].sort(),
|
|
24
|
+
property: i.property,
|
|
25
|
+
current: i.current,
|
|
26
|
+
expected: [...i.expected].sort(),
|
|
27
|
+
evidence: [...(i.evidence ?? [])]
|
|
28
|
+
.sort((a, b) => a.viewport.name.localeCompare(b.viewport.name))
|
|
29
|
+
.map((e) => ({
|
|
30
|
+
viewport: e.viewport,
|
|
31
|
+
...(e.state ? { state: e.state } : {}),
|
|
32
|
+
current: {
|
|
33
|
+
screenshotRef: reference(report.scan, "report.json", e.current.screenshot),
|
|
34
|
+
...(e.current.bounds ? { bounds: e.current.bounds } : {}),
|
|
35
|
+
},
|
|
36
|
+
...(e.baseline
|
|
37
|
+
? {
|
|
38
|
+
baseline: {
|
|
39
|
+
screenshotRef: reference(baseline, "baseline.json", e.baseline.screenshot),
|
|
40
|
+
...(e.baseline.bounds ? { bounds: e.baseline.bounds } : {}),
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
: {}),
|
|
44
|
+
})),
|
|
45
|
+
});
|
|
46
|
+
const summary = (issues) => ({
|
|
47
|
+
total: issues.length,
|
|
48
|
+
severity: Object.fromEntries(["high", "medium", "low"].map((s) => [
|
|
49
|
+
s,
|
|
50
|
+
issues.filter((i) => i.severity === s).length,
|
|
51
|
+
])),
|
|
52
|
+
category: Object.fromEntries([...categories, "layout"].map((c) => [
|
|
53
|
+
c,
|
|
54
|
+
issues.filter((i) => i.category === c).length,
|
|
55
|
+
])),
|
|
56
|
+
});
|
|
57
|
+
if (!report.approvals)
|
|
58
|
+
return {
|
|
59
|
+
schema: "tether.check",
|
|
60
|
+
version: 1,
|
|
61
|
+
summary: summary(report.issues),
|
|
62
|
+
findings: report.issues.map(finding),
|
|
63
|
+
};
|
|
64
|
+
const approved = report.approvals.approved;
|
|
12
65
|
return {
|
|
13
66
|
schema: "tether.check",
|
|
14
|
-
version:
|
|
67
|
+
version: 2,
|
|
15
68
|
summary: {
|
|
16
|
-
total: report.issues.length,
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
])),
|
|
21
|
-
category: Object.fromEntries([...categories, "layout"].map((c) => [
|
|
22
|
-
c,
|
|
23
|
-
report.issues.filter((i) => i.category === c).length,
|
|
24
|
-
])),
|
|
69
|
+
total: report.issues.length + approved.length,
|
|
70
|
+
active: summary(report.issues),
|
|
71
|
+
approved: summary(approved),
|
|
72
|
+
staleApprovals: report.approvals.stale.length,
|
|
25
73
|
},
|
|
26
|
-
findings:
|
|
27
|
-
|
|
28
|
-
...(i
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
title: i.title,
|
|
33
|
-
category: i.category,
|
|
34
|
-
severity: i.severity,
|
|
35
|
-
route: i.route,
|
|
36
|
-
viewports: [...i.viewports].sort(),
|
|
37
|
-
property: i.property,
|
|
38
|
-
current: i.current,
|
|
39
|
-
expected: [...i.expected].sort(),
|
|
40
|
-
evidence: [...(i.evidence ?? [])]
|
|
41
|
-
.sort((a, b) => a.viewport.name.localeCompare(b.viewport.name))
|
|
42
|
-
.map((e) => ({
|
|
43
|
-
viewport: e.viewport,
|
|
44
|
-
current: {
|
|
45
|
-
screenshotRef: reference(report.scan, "report.json", e.current.screenshot),
|
|
46
|
-
...(e.current.bounds ? { bounds: e.current.bounds } : {}),
|
|
47
|
-
},
|
|
48
|
-
...(e.baseline
|
|
49
|
-
? {
|
|
50
|
-
baseline: {
|
|
51
|
-
screenshotRef: reference(baseline, "baseline.json", e.baseline.screenshot),
|
|
52
|
-
...(e.baseline.bounds ? { bounds: e.baseline.bounds } : {}),
|
|
53
|
-
},
|
|
54
|
-
}
|
|
55
|
-
: {}),
|
|
74
|
+
findings: [
|
|
75
|
+
...report.issues.map((i) => ({ ...finding(i), status: "active" })),
|
|
76
|
+
...approved.map((i) => ({
|
|
77
|
+
...finding(i),
|
|
78
|
+
status: "approved",
|
|
79
|
+
approval: i.approval,
|
|
56
80
|
})),
|
|
57
|
-
|
|
81
|
+
],
|
|
82
|
+
staleApprovals: report.approvals.stale,
|
|
58
83
|
};
|
|
59
84
|
}
|
package/dist/drift/compare.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import type { Category, Contract, Issue, Scan, Severity } from "../types.js";
|
|
1
|
+
import type { Category, Contract, Issue, Scan, Severity, FindingOccurrence } from "../types.js";
|
|
2
2
|
export declare const labels: Record<Category | "layout", string>;
|
|
3
3
|
export declare function severityFor(category: Category, value: string, expected: string[]): Severity;
|
|
4
|
+
/** Atomic observations are retained until approval matching is complete. */
|
|
5
|
+
export declare function collectOccurrences(contract: Contract, baseline: Scan, current: Scan): FindingOccurrence[];
|
|
6
|
+
export declare function groupOccurrences(contract: Contract, occurrences: FindingOccurrence[]): Issue[];
|
|
4
7
|
export declare function compare(contract: Contract, baseline: Scan, current: Scan): Issue[];
|
|
5
8
|
export declare function consistencyScore(issues: Issue[]): number;
|
package/dist/drift/compare.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { safeUrl } from "../utils/scenarios.js";
|
|
1
2
|
import { resolveRule } from "../contract/scopes.js";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { baselineMatcher } from "../scanner/identity.js";
|
|
@@ -42,37 +43,42 @@ function equivalent(category, a, b) {
|
|
|
42
43
|
/^-?[\d.]+px$/.test(b) &&
|
|
43
44
|
Math.abs(parseFloat(a) - parseFloat(b)) <= 0.25);
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
|
|
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;
|
|
47
52
|
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
|
-
});
|
|
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
|
+
});
|
|
73
67
|
}
|
|
74
68
|
for (const snapshot of current.snapshots) {
|
|
75
|
-
|
|
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 &&
|
|
76
82
|
s.viewport.name === snapshot.viewport.name &&
|
|
77
83
|
s.viewport.width === snapshot.viewport.width &&
|
|
78
84
|
s.viewport.height === snapshot.viewport.height);
|
|
@@ -107,7 +113,7 @@ export function compare(contract, baseline, current) {
|
|
|
107
113
|
current: o.value,
|
|
108
114
|
expected,
|
|
109
115
|
route: snapshot.route,
|
|
110
|
-
}, snapshot.viewport
|
|
116
|
+
}, snapshot.viewport);
|
|
111
117
|
}
|
|
112
118
|
}
|
|
113
119
|
for (const problem of snapshot.layout) {
|
|
@@ -130,9 +136,45 @@ export function compare(contract, baseline, current) {
|
|
|
130
136
|
current: problem.value,
|
|
131
137
|
expected: ["Content fits within the viewport"],
|
|
132
138
|
route: snapshot.route,
|
|
133
|
-
}, snapshot.viewport
|
|
139
|
+
}, snapshot.viewport);
|
|
140
|
+
}
|
|
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}`;
|
|
134
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
|
+
});
|
|
135
175
|
}
|
|
176
|
+
for (const occurrence of occurrences)
|
|
177
|
+
push(occurrence.issue, occurrence.viewport.name);
|
|
136
178
|
const rank = { high: 0, medium: 1, low: 2 };
|
|
137
179
|
if (contract.version === 2)
|
|
138
180
|
for (const issue of issues.values()) {
|
|
@@ -142,6 +184,7 @@ export function compare(contract, baseline, current) {
|
|
|
142
184
|
return [...issues.values()].sort((a, b) => rank[a.severity] - rank[b.severity] ||
|
|
143
185
|
a.category.localeCompare(b.category) ||
|
|
144
186
|
a.route.localeCompare(b.route) ||
|
|
187
|
+
(a.state ?? "default").localeCompare(b.state ?? "default") ||
|
|
145
188
|
(a.identity ?? a.selector).localeCompare(b.identity ?? b.selector) ||
|
|
146
189
|
a.current.localeCompare(b.current) ||
|
|
147
190
|
(contract.version === 2
|
|
@@ -149,6 +192,9 @@ export function compare(contract, baseline, current) {
|
|
|
149
192
|
JSON.stringify(a.rule ?? null).localeCompare(JSON.stringify(b.rule ?? null))
|
|
150
193
|
: 0));
|
|
151
194
|
}
|
|
195
|
+
export function compare(contract, baseline, current) {
|
|
196
|
+
return groupOccurrences(contract, collectOccurrences(contract, baseline, current));
|
|
197
|
+
}
|
|
152
198
|
export function consistencyScore(issues) {
|
|
153
199
|
return Math.max(0, 100 -
|
|
154
200
|
issues.reduce((sum, i) => sum + { high: 8, medium: 4, low: 2 }[i.severity], 0));
|
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,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
|
});
|