@penpair/core 0.2.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/dist/adapters.js +58 -11
- package/dist/approvals.js +143 -29
- package/dist/atomic-output.js +9 -4
- package/dist/audit-rules.js +345 -79
- package/dist/backup-note.js +29 -8
- package/dist/baseline-coverage.js +169 -35
- package/dist/baselines-config.js +119 -29
- package/dist/baselines.js +280 -57
- package/dist/cli-arguments.js +18 -6
- package/dist/cli-main.js +1621 -392
- package/dist/client.d.ts +5 -2
- package/dist/client.js +127 -28
- package/dist/command-result.js +341 -52
- package/dist/coordination.js +91 -29
- package/dist/credentials.js +57 -13
- package/dist/deep-check.js +56 -12
- package/dist/design-spec.js +189 -49
- package/dist/endpoint.js +102 -34
- package/dist/export.js +86 -18
- package/dist/file-archive.js +33 -7
- package/dist/file-guard.js +29 -11
- package/dist/formats.js +215 -14
- package/dist/index.d.ts +8 -7
- package/dist/index.js +8 -7
- package/dist/inspection.d.ts +1 -1
- package/dist/inspection.js +378 -65
- package/dist/instance/config-schema.js +400 -41
- package/dist/instance/config.js +101 -30
- package/dist/instance/connect.js +151 -44
- package/dist/instance/connection.d.ts +26 -5
- package/dist/instance/connection.js +194 -21
- package/dist/instance/gitignore.js +5 -2
- package/dist/instance/init.js +32 -7
- package/dist/instance/runner.js +32 -10
- package/dist/instance/setup.js +46 -13
- package/dist/instance/tty.js +26 -8
- package/dist/journal.d.ts +10 -0
- package/dist/journal.js +212 -49
- package/dist/kit-smoke.js +82 -20
- package/dist/kit-source.js +10 -40
- package/dist/kit.js +102 -34
- package/dist/layout-policy.d.ts +2 -0
- package/dist/layout-policy.js +86 -0
- package/dist/layout.d.ts +7 -1
- package/dist/layout.js +27 -70
- package/dist/live-smoke.js +13 -4
- package/dist/local-profile.js +2 -1
- package/dist/mcp-server.js +360 -47
- package/dist/native-api.js +47 -11
- package/dist/onboard.js +33 -7
- package/dist/operation.d.ts +2 -2
- package/dist/operation.js +279 -69
- package/dist/page-session.d.ts +23 -0
- package/dist/page-session.js +142 -0
- package/dist/png.js +18 -3
- package/dist/portable-tokens.js +254 -57
- package/dist/probe.d.ts +1 -1
- package/dist/probe.js +48 -11
- package/dist/project-input.js +24 -10
- package/dist/project-snippets.js +18 -6
- package/dist/project-status.js +133 -24
- package/dist/registry.js +217 -60
- package/dist/run-file.js +129 -30
- package/dist/shots.js +108 -26
- package/dist/skill-instructions.js +91 -22
- package/dist/skills.js +129 -39
- package/dist/snippet.js +27 -7
- package/dist/snippets/read.js +8 -2
- package/dist/structure-snapshot.d.ts +12 -0
- package/dist/structure-snapshot.js +439 -85
- package/dist/token-edit.js +219 -64
- package/dist/token-projection.d.ts +56 -0
- package/dist/token-projection.js +286 -0
- package/dist/tokens-diff.js +125 -32
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/skills/penpair-design/SKILL.md +3 -0
- package/skills/penpair-design/references/penpot-plugin-gotchas.md +2 -2
- package/skills/penpair-design/references/project-facts.md +2 -0
- package/skills/penpair-handoff/SKILL.md +2 -0
- package/skills/penpair-handoff/references/comparison-companion.md +13 -0
- package/skills/penpair-handoff/references/handoff-flow.md +2 -0
- package/skills/penpair-recipes/SKILL.md +5 -0
- package/skills/penpair-setup/SKILL.md +3 -1
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -3
- package/dist/docker/lifecycle.d.ts +0 -96
- package/dist/docker/lifecycle.js +0 -273
- package/dist/docker/secrets.d.ts +0 -11
- package/dist/docker/secrets.js +0 -38
- package/dist/docker/template.d.ts +0 -8
- package/dist/docker/template.js +0 -158
- package/dist/docker-profile.d.ts +0 -114
- package/dist/docker-profile.js +0 -18
- package/dist/instance/lifecycle.d.ts +0 -129
- package/dist/instance/lifecycle.js +0 -336
- package/dist/instance/secrets.d.ts +0 -11
- package/dist/instance/secrets.js +0 -38
- package/dist/instance/template.d.ts +0 -8
- package/dist/instance/template.js +0 -158
|
@@ -3,61 +3,158 @@ import path from "node:path";
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { resolveProjectOutput } from "./project-input.js";
|
|
5
5
|
import { pngSize } from "./png.js";
|
|
6
|
-
import { validateBaselines } from "./baselines-config.js";
|
|
6
|
+
import { validateBaselines, } from "./baselines-config.js";
|
|
7
7
|
import { observationHash } from "./journal.js";
|
|
8
|
-
export const designCaseKey = (route, value) => observationHash({
|
|
8
|
+
export const designCaseKey = (route, value) => observationHash({
|
|
9
|
+
route,
|
|
10
|
+
viewport: {
|
|
11
|
+
width: value.viewport.width,
|
|
12
|
+
height: value.viewport.height,
|
|
13
|
+
},
|
|
14
|
+
theme: value.theme,
|
|
15
|
+
state: value.state,
|
|
16
|
+
fixture: value.fixture,
|
|
17
|
+
});
|
|
9
18
|
export function baselineCoverage(config, results = []) {
|
|
10
19
|
const rows = [];
|
|
11
20
|
for (const requirement of config.coverage?.required ?? []) {
|
|
12
21
|
const key = designCaseKey(requirement.route, requirement);
|
|
13
|
-
const unsupported = config.coverage?.unsupported?.find(value => designCaseKey(value.route, value) === key);
|
|
14
|
-
const frames = config.frames.filter(frame => frame.route && frame.case && designCaseKey(frame.route, frame.case) === key);
|
|
15
|
-
const frameIds = frames.map(frame => frame.id);
|
|
22
|
+
const unsupported = config.coverage?.unsupported?.find((value) => designCaseKey(value.route, value) === key);
|
|
23
|
+
const frames = config.frames.filter((frame) => frame.route && frame.case && designCaseKey(frame.route, frame.case) === key);
|
|
24
|
+
const frameIds = frames.map((frame) => frame.id);
|
|
16
25
|
if (unsupported) {
|
|
17
|
-
rows.push({
|
|
26
|
+
rows.push({
|
|
27
|
+
requirement,
|
|
28
|
+
status: "unsupported",
|
|
29
|
+
frameIds,
|
|
30
|
+
reason: unsupported.reason,
|
|
31
|
+
});
|
|
18
32
|
continue;
|
|
19
33
|
}
|
|
20
34
|
if (!frames.length) {
|
|
21
|
-
rows.push({
|
|
35
|
+
rows.push({
|
|
36
|
+
requirement,
|
|
37
|
+
status: "missing-case",
|
|
38
|
+
frameIds,
|
|
39
|
+
});
|
|
22
40
|
continue;
|
|
23
41
|
}
|
|
24
|
-
const captures = frames.flatMap(
|
|
42
|
+
const captures = frames.flatMap((frame) => {
|
|
43
|
+
const found = results.find((result) => result.id === frame.id);
|
|
44
|
+
return found
|
|
45
|
+
? [
|
|
46
|
+
{
|
|
47
|
+
frame,
|
|
48
|
+
result: found,
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
: [];
|
|
52
|
+
});
|
|
25
53
|
if (!captures.length || captures.every(({ result }) => !result.approved)) {
|
|
26
|
-
rows.push({
|
|
54
|
+
rows.push({
|
|
55
|
+
requirement,
|
|
56
|
+
status: "missing-capture",
|
|
57
|
+
frameIds,
|
|
58
|
+
});
|
|
27
59
|
continue;
|
|
28
60
|
}
|
|
29
61
|
const approved = captures.some(({ frame, result }) => {
|
|
30
62
|
const evidence = result.approved && "case" in result.approved ? result.approved : undefined;
|
|
31
|
-
return evidence?.approval?.kind !== "forced-reference" &&
|
|
63
|
+
return (evidence?.approval?.kind !== "forced-reference" &&
|
|
64
|
+
![
|
|
65
|
+
"missing",
|
|
66
|
+
"unstable",
|
|
67
|
+
"case-mismatch",
|
|
68
|
+
].includes(result.status) &&
|
|
69
|
+
evidence?.case &&
|
|
70
|
+
observationHash(evidence.case) === observationHash(frame.case) &&
|
|
71
|
+
result.sha256 === evidence.sha256 &&
|
|
72
|
+
evidence.frameId === result.frameId &&
|
|
73
|
+
evidence.file === result.file &&
|
|
74
|
+
(!result.captured ||
|
|
75
|
+
(evidence.fileId === result.captured.fileId &&
|
|
76
|
+
evidence.pageId === result.captured.pageId)) &&
|
|
77
|
+
evidence.designSize?.width === frame.case.viewport.width &&
|
|
78
|
+
evidence.designSize?.height === frame.case.viewport.height &&
|
|
79
|
+
result.imageWidth === Math.round(frame.case.viewport.width * evidence.scale) &&
|
|
80
|
+
result.imageHeight === Math.round(frame.case.viewport.height * evidence.scale) &&
|
|
81
|
+
(!frame.case.activeThemeIds ||
|
|
82
|
+
(evidence.activeThemeIds &&
|
|
83
|
+
observationHash([
|
|
84
|
+
...frame.case.activeThemeIds,
|
|
85
|
+
].sort()) ===
|
|
86
|
+
observationHash([
|
|
87
|
+
...evidence.activeThemeIds,
|
|
88
|
+
].sort()))));
|
|
89
|
+
});
|
|
90
|
+
const changed = approved &&
|
|
91
|
+
captures.some(({ result }) => result.status === "changed" &&
|
|
92
|
+
result.captured &&
|
|
93
|
+
result.approved &&
|
|
94
|
+
result.captured.sha256 !== result.approved.sha256);
|
|
95
|
+
rows.push({
|
|
96
|
+
requirement,
|
|
97
|
+
status: changed
|
|
98
|
+
? "changed-unapproved"
|
|
99
|
+
: approved
|
|
100
|
+
? "approved-design-intent"
|
|
101
|
+
: "unapproved-metadata",
|
|
102
|
+
frameIds,
|
|
103
|
+
...(!approved
|
|
104
|
+
? {
|
|
105
|
+
reason: captures.flatMap(({ result }) => result.problems ?? []).join("; ") ||
|
|
106
|
+
"Approved case/viewport/theme evidence is absent or mismatched",
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
32
109
|
});
|
|
33
|
-
const changed = approved && captures.some(({ result }) => result.status === "changed" && result.captured && result.approved && result.captured.sha256 !== result.approved.sha256);
|
|
34
|
-
rows.push({ requirement, status: changed ? "changed-unapproved" : approved ? "approved-design-intent" : "unapproved-metadata", frameIds, ...(!approved ? { reason: captures.flatMap(({ result }) => result.problems ?? []).join("; ") || "Approved case/viewport/theme evidence is absent or mismatched" } : {}) });
|
|
35
110
|
}
|
|
36
|
-
const unknown = config.frames.flatMap(frame => {
|
|
111
|
+
const unknown = config.frames.flatMap((frame) => {
|
|
37
112
|
const fields = [];
|
|
38
|
-
if (!frame.case)
|
|
113
|
+
if (!frame.case) {
|
|
39
114
|
fields.push("viewport", "theme", "state", "fixture", "font environment");
|
|
115
|
+
}
|
|
40
116
|
else {
|
|
41
|
-
if (frame.case.fonts === undefined)
|
|
117
|
+
if (frame.case.fonts === undefined) {
|
|
42
118
|
fields.push("declared fonts absent");
|
|
43
|
-
|
|
119
|
+
}
|
|
120
|
+
else if (frame.case.fonts.length) {
|
|
44
121
|
fields.push("font loading/version not observed");
|
|
45
|
-
|
|
122
|
+
}
|
|
123
|
+
if (frame.case.activeThemeIds === undefined) {
|
|
46
124
|
fields.push("theme activation not asserted; label is design intent");
|
|
125
|
+
}
|
|
47
126
|
}
|
|
48
|
-
const result = results.find(result => result.id === frame.id);
|
|
49
|
-
if (frame.case && !result?.captured?.renderEnvironment)
|
|
127
|
+
const result = results.find((result) => result.id === frame.id);
|
|
128
|
+
if (frame.case && !result?.captured?.renderEnvironment) {
|
|
50
129
|
fields.push("renderer environment not observed");
|
|
51
|
-
|
|
130
|
+
}
|
|
131
|
+
return fields.length
|
|
132
|
+
? [
|
|
133
|
+
{
|
|
134
|
+
id: frame.id,
|
|
135
|
+
fields,
|
|
136
|
+
},
|
|
137
|
+
]
|
|
138
|
+
: [];
|
|
52
139
|
});
|
|
53
|
-
return {
|
|
140
|
+
return {
|
|
141
|
+
schemaVersion: 1,
|
|
142
|
+
kind: "baseline-coverage",
|
|
143
|
+
configured: Boolean(config.coverage),
|
|
144
|
+
designCasesComplete: rows.length > 0 && rows.every((row) => row.status === "approved-design-intent"),
|
|
145
|
+
rows,
|
|
146
|
+
unknown,
|
|
147
|
+
browserVerification: "not-run",
|
|
148
|
+
accessibility: "not-assessed",
|
|
149
|
+
};
|
|
54
150
|
}
|
|
55
151
|
async function boundedRead(file, maximum) {
|
|
56
152
|
const handle = await open(file, "r");
|
|
57
153
|
try {
|
|
58
154
|
const info = await handle.stat();
|
|
59
|
-
if (!info.isFile() || info.size > maximum)
|
|
155
|
+
if (!info.isFile() || info.size > maximum) {
|
|
60
156
|
throw new Error("Artifact exceeds the coverage read budget or is not a regular file");
|
|
157
|
+
}
|
|
61
158
|
return await handle.readFile();
|
|
62
159
|
}
|
|
63
160
|
finally {
|
|
@@ -67,47 +164,84 @@ async function boundedRead(file, maximum) {
|
|
|
67
164
|
/** Offline coverage validates the approved image bytes, never only a manifest's claims. */
|
|
68
165
|
export async function readBaselineCoverage(root, config, expectedFileId) {
|
|
69
166
|
config = structuredClone(config);
|
|
70
|
-
validateBaselines(config, message => {
|
|
167
|
+
validateBaselines(config, (message) => {
|
|
168
|
+
throw new Error(message);
|
|
169
|
+
});
|
|
71
170
|
const file = await resolveProjectOutput(root, path.join(config.root, "penpair-baselines.json"));
|
|
72
171
|
let manifest;
|
|
73
172
|
try {
|
|
74
173
|
manifest = JSON.parse((await boundedRead(file, 16 * 1024 * 1024)).toString("utf8"));
|
|
75
174
|
}
|
|
76
175
|
catch (error) {
|
|
77
|
-
if (error.code !== "ENOENT")
|
|
176
|
+
if (error.code !== "ENOENT") {
|
|
78
177
|
throw error;
|
|
178
|
+
}
|
|
79
179
|
}
|
|
80
|
-
if (!manifest)
|
|
180
|
+
if (!manifest) {
|
|
81
181
|
return baselineCoverage(config);
|
|
82
|
-
|
|
182
|
+
}
|
|
183
|
+
if (manifest.schemaVersion !== 2 ||
|
|
184
|
+
!Array.isArray(manifest.frames) ||
|
|
185
|
+
manifest.frames.length > 1000) {
|
|
83
186
|
throw new Error("Coverage needs a bounded schema-2 baseline manifest; regenerate a reviewed capture");
|
|
84
|
-
|
|
187
|
+
}
|
|
188
|
+
if (expectedFileId && manifest.fileId !== expectedFileId) {
|
|
85
189
|
throw new Error("Coverage manifest belongs to a different pinned file");
|
|
190
|
+
}
|
|
86
191
|
const ids = new Set();
|
|
87
192
|
for (const value of manifest.frames) {
|
|
88
|
-
if (!value ||
|
|
193
|
+
if (!value ||
|
|
194
|
+
typeof value.id !== "string" ||
|
|
195
|
+
!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value.id) ||
|
|
196
|
+
ids.has(value.id) ||
|
|
197
|
+
![
|
|
198
|
+
"new",
|
|
199
|
+
"same",
|
|
200
|
+
"changed",
|
|
201
|
+
"missing",
|
|
202
|
+
"unstable",
|
|
203
|
+
"case-mismatch",
|
|
204
|
+
].includes(value.status) ||
|
|
205
|
+
typeof value.file !== "string" ||
|
|
206
|
+
!value.file) {
|
|
89
207
|
throw new Error("Malformed baseline frame records");
|
|
208
|
+
}
|
|
90
209
|
ids.add(value.id);
|
|
91
210
|
}
|
|
92
|
-
const results = manifest.frames.map(value => ({
|
|
211
|
+
const results = manifest.frames.map((value) => ({
|
|
212
|
+
...value,
|
|
213
|
+
}));
|
|
93
214
|
for (const result of results) {
|
|
94
|
-
if (!result.approved)
|
|
215
|
+
if (!result.approved) {
|
|
95
216
|
continue;
|
|
217
|
+
}
|
|
96
218
|
try {
|
|
97
|
-
if (typeof result.file !== "string" ||
|
|
219
|
+
if (typeof result.file !== "string" ||
|
|
220
|
+
path.isAbsolute(result.file) ||
|
|
221
|
+
result.file.split(/[\\/]/).includes("..")) {
|
|
98
222
|
throw new Error("Approved image path leaves its baseline folder");
|
|
99
|
-
|
|
223
|
+
}
|
|
224
|
+
if ("fileId" in result.approved && result.approved.fileId !== manifest.fileId) {
|
|
100
225
|
throw new Error("Approved image provenance belongs to a different file");
|
|
226
|
+
}
|
|
101
227
|
const target = await resolveProjectOutput(root, path.join(config.root, result.file));
|
|
102
|
-
const bytes = await boundedRead(target, 64 * 1024 * 1024)
|
|
103
|
-
|
|
228
|
+
const bytes = await boundedRead(target, 64 * 1024 * 1024);
|
|
229
|
+
const size = pngSize(bytes);
|
|
230
|
+
if (!size ||
|
|
231
|
+
size.width < 1 ||
|
|
232
|
+
size.height < 1 ||
|
|
233
|
+
createHash("sha256").update(bytes).digest("hex") !== result.approved.sha256 ||
|
|
234
|
+
result.sha256 !== result.approved.sha256) {
|
|
104
235
|
throw new Error("Approved PNG bytes do not match their manifest");
|
|
236
|
+
}
|
|
105
237
|
result.imageWidth = size.width;
|
|
106
238
|
result.imageHeight = size.height;
|
|
107
239
|
}
|
|
108
240
|
catch (error) {
|
|
109
241
|
result.status = "case-mismatch";
|
|
110
|
-
result.problems = [
|
|
242
|
+
result.problems = [
|
|
243
|
+
error instanceof Error ? error.message : String(error),
|
|
244
|
+
];
|
|
111
245
|
}
|
|
112
246
|
}
|
|
113
247
|
return baselineCoverage(config, results);
|
package/dist/baselines-config.js
CHANGED
|
@@ -1,80 +1,170 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
const idPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
3
3
|
export function validateBaselines(value, fail) {
|
|
4
|
-
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
4
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
5
5
|
return fail("baselines must be an object");
|
|
6
|
+
}
|
|
6
7
|
const v = value;
|
|
7
|
-
if (typeof v.page !== "string" || v.page.length === 0)
|
|
8
|
+
if (typeof v.page !== "string" || v.page.length === 0) {
|
|
8
9
|
fail("baselines.page must be the name of a page");
|
|
9
|
-
|
|
10
|
+
}
|
|
11
|
+
if (typeof v.root !== "string" ||
|
|
12
|
+
v.root.length === 0 ||
|
|
13
|
+
path.isAbsolute(v.root) ||
|
|
14
|
+
v.root.split(/[\\/]/).includes("..")) {
|
|
10
15
|
fail("baselines.root must be a relative folder that stays inside the project");
|
|
11
|
-
|
|
16
|
+
}
|
|
17
|
+
if (!Array.isArray(v.frames) || v.frames.length === 0 || v.frames.length > 1000) {
|
|
12
18
|
fail("baselines.frames must list at least one frame, at most 1000");
|
|
19
|
+
}
|
|
13
20
|
const seen = new Set();
|
|
14
21
|
for (const entry of v.frames) {
|
|
15
22
|
const f = entry;
|
|
16
|
-
if (typeof f !== "object" || f === null)
|
|
23
|
+
if (typeof f !== "object" || f === null) {
|
|
17
24
|
fail("every entry in baselines.frames must be an object");
|
|
18
|
-
|
|
25
|
+
}
|
|
26
|
+
if (typeof f.frame !== "string" || f.frame.length === 0) {
|
|
19
27
|
fail("every baselines.frames entry needs a frame name");
|
|
20
|
-
|
|
28
|
+
}
|
|
29
|
+
if (typeof f.id !== "string" || !idPattern.test(f.id)) {
|
|
21
30
|
fail(`baselines.frames id ${JSON.stringify(f.id)} must be lowercase letters, digits, hyphens or underscores`);
|
|
22
|
-
|
|
31
|
+
}
|
|
32
|
+
if (seen.has(f.id)) {
|
|
23
33
|
fail(`baselines.frames id ${JSON.stringify(f.id)} is used twice`);
|
|
34
|
+
}
|
|
24
35
|
seen.add(f.id);
|
|
25
36
|
if (f.case !== undefined) {
|
|
26
37
|
validateBaselineCase(f.case, fail);
|
|
27
|
-
if (typeof f.route !== "string" || !f.route)
|
|
38
|
+
if (typeof f.route !== "string" || !f.route) {
|
|
28
39
|
fail("baseline case metadata requires an implementation route");
|
|
40
|
+
}
|
|
29
41
|
}
|
|
30
|
-
if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0))
|
|
42
|
+
if (f.route !== undefined && (typeof f.route !== "string" || f.route.length === 0)) {
|
|
31
43
|
fail("baselines.frames route must be a non-empty string");
|
|
44
|
+
}
|
|
32
45
|
}
|
|
33
46
|
if (v.coverage !== undefined) {
|
|
34
47
|
const coverage = v.coverage;
|
|
35
|
-
if (!coverage ||
|
|
48
|
+
if (!coverage ||
|
|
49
|
+
typeof coverage !== "object" ||
|
|
50
|
+
Array.isArray(coverage) ||
|
|
51
|
+
!Array.isArray(coverage.required) ||
|
|
52
|
+
!coverage.required.length ||
|
|
53
|
+
coverage.required.length > 1000 ||
|
|
54
|
+
(coverage.unsupported !== undefined &&
|
|
55
|
+
(!Array.isArray(coverage.unsupported) || coverage.unsupported.length > 1000))) {
|
|
36
56
|
fail("baselines.coverage needs 1–1000 required cases and bounded unsupported cases");
|
|
57
|
+
}
|
|
37
58
|
const required = new Set();
|
|
38
|
-
const key = (value) => JSON.stringify([
|
|
59
|
+
const key = (value) => JSON.stringify([
|
|
60
|
+
value.route,
|
|
61
|
+
value.viewport.width,
|
|
62
|
+
value.viewport.height,
|
|
63
|
+
value.theme,
|
|
64
|
+
value.state,
|
|
65
|
+
value.fixture,
|
|
66
|
+
]);
|
|
39
67
|
for (const value of coverage.required) {
|
|
40
|
-
validateBaselineCase(value, fail, [
|
|
41
|
-
|
|
68
|
+
validateBaselineCase(value, fail, [
|
|
69
|
+
"route",
|
|
70
|
+
]);
|
|
71
|
+
if (typeof value.route !== "string" || !value.route) {
|
|
42
72
|
fail("coverage cases need a route");
|
|
43
|
-
|
|
73
|
+
}
|
|
74
|
+
if (required.has(key(value))) {
|
|
44
75
|
fail("Duplicate required coverage case");
|
|
76
|
+
}
|
|
45
77
|
required.add(key(value));
|
|
46
78
|
}
|
|
47
79
|
const unsupported = new Set();
|
|
48
80
|
for (const value of coverage.unsupported ?? []) {
|
|
49
|
-
validateBaselineCase(value, fail, [
|
|
50
|
-
|
|
81
|
+
validateBaselineCase(value, fail, [
|
|
82
|
+
"route",
|
|
83
|
+
"reason",
|
|
84
|
+
]);
|
|
85
|
+
if (typeof value.route !== "string" ||
|
|
86
|
+
!value.route ||
|
|
87
|
+
typeof value.reason !== "string" ||
|
|
88
|
+
!value.reason.trim()) {
|
|
51
89
|
fail("unsupported cases need route and reason");
|
|
52
|
-
|
|
90
|
+
}
|
|
91
|
+
if (!required.has(key(value)) || unsupported.has(key(value))) {
|
|
53
92
|
fail("unsupported cases must uniquely reference required cases");
|
|
93
|
+
}
|
|
54
94
|
unsupported.add(key(value));
|
|
55
95
|
}
|
|
56
96
|
}
|
|
57
|
-
if (v.threshold !== undefined &&
|
|
97
|
+
if (v.threshold !== undefined &&
|
|
98
|
+
(typeof v.threshold !== "number" ||
|
|
99
|
+
!Number.isFinite(v.threshold) ||
|
|
100
|
+
v.threshold < 0 ||
|
|
101
|
+
v.threshold > 1)) {
|
|
58
102
|
fail("baselines.threshold must be a number from 0 to 1");
|
|
103
|
+
}
|
|
59
104
|
return v;
|
|
60
105
|
}
|
|
61
106
|
export function validateBaselineCase(value, fail, extraKeys = []) {
|
|
62
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
63
108
|
return fail("baseline case must be an object");
|
|
109
|
+
}
|
|
64
110
|
const entry = value;
|
|
65
|
-
const allowed = new Set([
|
|
66
|
-
|
|
111
|
+
const allowed = new Set([
|
|
112
|
+
"viewport",
|
|
113
|
+
"theme",
|
|
114
|
+
"state",
|
|
115
|
+
"fixture",
|
|
116
|
+
...(extraKeys.length
|
|
117
|
+
? []
|
|
118
|
+
: [
|
|
119
|
+
"fonts",
|
|
120
|
+
"activeThemeIds",
|
|
121
|
+
]),
|
|
122
|
+
...extraKeys,
|
|
123
|
+
]);
|
|
124
|
+
if (Object.keys(entry).some((key) => !allowed.has(key))) {
|
|
67
125
|
fail("Unknown baseline case field; capture constraints belong on frame cases");
|
|
68
|
-
|
|
126
|
+
}
|
|
127
|
+
if (entry.viewport &&
|
|
128
|
+
Object.keys(entry.viewport).some((key) => ![
|
|
129
|
+
"width",
|
|
130
|
+
"height",
|
|
131
|
+
].includes(key))) {
|
|
69
132
|
fail("Viewport accepts width and height only");
|
|
70
|
-
|
|
71
|
-
|
|
133
|
+
}
|
|
134
|
+
for (const key of [
|
|
135
|
+
"theme",
|
|
136
|
+
"state",
|
|
137
|
+
"fixture",
|
|
138
|
+
]) {
|
|
139
|
+
if (typeof entry[key] !== "string" ||
|
|
140
|
+
!entry[key].trim() ||
|
|
141
|
+
entry[key].length > 200 ||
|
|
142
|
+
/[\x00-\x1f]/.test(entry[key])) {
|
|
72
143
|
fail(`baseline case ${key} needs a short explicit label`);
|
|
73
|
-
|
|
74
|
-
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const key of [
|
|
147
|
+
"width",
|
|
148
|
+
"height",
|
|
149
|
+
]) {
|
|
150
|
+
if (!entry.viewport ||
|
|
151
|
+
!Number.isInteger(entry.viewport[key]) ||
|
|
152
|
+
entry.viewport[key] < 1 ||
|
|
153
|
+
entry.viewport[key] > 16384) {
|
|
75
154
|
fail("baseline case viewport dimensions must be integers from 1 to 16384");
|
|
76
|
-
|
|
77
|
-
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
for (const key of [
|
|
158
|
+
"fonts",
|
|
159
|
+
"activeThemeIds",
|
|
160
|
+
]) {
|
|
161
|
+
if (entry[key] !== undefined &&
|
|
162
|
+
(!Array.isArray(entry[key]) ||
|
|
163
|
+
entry[key].length > 100 ||
|
|
164
|
+
entry[key].some((value) => typeof value !== "string" || !value.trim()) ||
|
|
165
|
+
new Set(entry[key]).size !== entry[key].length)) {
|
|
78
166
|
fail(`baseline case ${key} must contain at most 100 unique nonempty strings`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
79
169
|
return entry;
|
|
80
170
|
}
|