@aethervtt/shard-verify 0.0.0-stage → 0.2.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/plan.js ADDED
@@ -0,0 +1,250 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { validateJson } from "./record.js";
3
+ const tolerance = {
4
+ type: "object",
5
+ additionalProperties: false,
6
+ properties: {
7
+ deltaE: { type: "number", exclusiveMinimum: 0 },
8
+ maxShare: { type: "number", minimum: 0 },
9
+ minSsim: { type: "number", minimum: 0 }
10
+ }
11
+ };
12
+ const shot = {
13
+ type: "object",
14
+ additionalProperties: false,
15
+ required: ["name"],
16
+ properties: {
17
+ name: { type: "string", minLength: 1 },
18
+ state: { type: "object" },
19
+ scope: { enum: ["canvas", "page"] },
20
+ canvas: { type: "string", minLength: 1 },
21
+ tolerance
22
+ }
23
+ };
24
+ const matcher = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ equals: {},
29
+ includes: { type: "array" },
30
+ excludes: { type: "array" },
31
+ min: { type: "number" },
32
+ max: { type: "number" },
33
+ sameAs: { type: "string", minLength: 1 }
34
+ }
35
+ };
36
+ const strings = { type: "array", items: { type: "string", minLength: 1 } };
37
+ const step = {
38
+ type: "object",
39
+ additionalProperties: false,
40
+ required: ["name"],
41
+ properties: {
42
+ name: { type: "string", minLength: 1 },
43
+ run: { type: "string", minLength: 1 },
44
+ args: {},
45
+ clients: strings,
46
+ trace: { type: "boolean" },
47
+ expect: {
48
+ type: "object",
49
+ additionalProperties: { type: "object", additionalProperties: matcher }
50
+ },
51
+ capture: {}
52
+ }
53
+ };
54
+ function capturePlanJsonSchema() {
55
+ return {
56
+ $schema: "https://json-schema.org/draft/2020-12/schema",
57
+ $id: "https://shard.dev/schemas/capture-plan.schema.json",
58
+ title: "Shard capture plan",
59
+ description: "Browser captures, host steps with checks, and measured scenarios with pass thresholds (spec 0062).",
60
+ type: "object",
61
+ additionalProperties: false,
62
+ required: ["url"],
63
+ properties: {
64
+ $schema: { type: "string" },
65
+ url: { type: "string", minLength: 1 },
66
+ browsers: { type: "array", items: { enum: ["chromium", "webkit", "firefox"] }, minItems: 1 },
67
+ dpr: { type: "array", items: { type: "number", exclusiveMinimum: 0 }, minItems: 1 },
68
+ viewport: { type: "array", items: { type: "integer", minimum: 1 }, minItems: 2, maxItems: 2 },
69
+ scope: { enum: ["canvas", "page"] },
70
+ canvas: { type: "string", minLength: 1 },
71
+ fixture: { type: "string", minLength: 1 },
72
+ renderer: { type: "string", minLength: 1 },
73
+ backend: { enum: ["auto", "webgpu", "webgl2"] },
74
+ conditions: {
75
+ type: "object",
76
+ additionalProperties: false,
77
+ properties: {
78
+ renderScale: {
79
+ type: "object",
80
+ additionalProperties: false,
81
+ required: ["mode", "scale"],
82
+ properties: {
83
+ mode: { const: "fixed" },
84
+ scale: { type: "number", exclusiveMinimum: 0 }
85
+ }
86
+ }
87
+ }
88
+ },
89
+ tolerance,
90
+ clients: {
91
+ type: "array",
92
+ minItems: 1,
93
+ items: {
94
+ type: "object",
95
+ additionalProperties: false,
96
+ required: ["name"],
97
+ properties: {
98
+ name: { type: "string", minLength: 1 },
99
+ role: { type: "string", minLength: 1 },
100
+ query: { type: "object", additionalProperties: { type: "string" } },
101
+ shots: { type: "array", items: shot }
102
+ }
103
+ }
104
+ },
105
+ shots: { type: "array", items: shot },
106
+ steps: { type: "array", items: step },
107
+ scenarios: {
108
+ type: "array",
109
+ items: {
110
+ type: "object",
111
+ additionalProperties: false,
112
+ required: ["name", "seconds"],
113
+ properties: {
114
+ name: { type: "string", minLength: 1 },
115
+ steps: { type: "array", items: step },
116
+ seconds: { type: "number", minimum: 0 },
117
+ clients: strings
118
+ }
119
+ }
120
+ },
121
+ thresholds: {
122
+ type: "object",
123
+ additionalProperties: {
124
+ type: "object",
125
+ properties: {
126
+ spans: {
127
+ type: "array",
128
+ description: `Span budgets on records' breakdown (spec 0074): { "span": "partition/spawn", "p95": 2 }.`,
129
+ items: {
130
+ type: "object",
131
+ additionalProperties: false,
132
+ required: ["span"],
133
+ properties: {
134
+ span: { type: "string", minLength: 1 },
135
+ p50: { type: "number", minimum: 0 },
136
+ p95: { type: "number", minimum: 0 },
137
+ max: { type: "number", minimum: 0 }
138
+ }
139
+ }
140
+ }
141
+ },
142
+ additionalProperties: {
143
+ type: "object",
144
+ additionalProperties: false,
145
+ properties: {
146
+ max: { type: "number" },
147
+ min: { type: "number" },
148
+ maxRatioTo: { type: "string", minLength: 1 },
149
+ ratio: { type: "number", exclusiveMinimum: 0 }
150
+ }
151
+ }
152
+ }
153
+ },
154
+ timeoutMs: { type: "number", exclusiveMinimum: 0 }
155
+ }
156
+ };
157
+ }
158
+ function parsePlan(json, file) {
159
+ const errors = validateJson(capturePlanJsonSchema(), json);
160
+ const raw = json;
161
+ if (errors.length === 0) errors.push(...referenceErrors(raw));
162
+ if (errors.length > 0) {
163
+ throw new ShardError(
164
+ "verify/invalid-plan",
165
+ `${file ?? "The plan"} has ${errors.length} problem${errors.length === 1 ? "" : "s"}: ${errors[0].message} at ${errors[0].path}`,
166
+ {
167
+ path: file,
168
+ details: errors,
169
+ hint: "Plans follow .shard/schemas/capture-plan.schema.json."
170
+ }
171
+ );
172
+ }
173
+ const url = new URL(raw.url, "http://localhost/");
174
+ return {
175
+ url: raw.url,
176
+ browsers: raw.browsers ?? ["chromium"],
177
+ dpr: raw.dpr ?? [1],
178
+ viewport: raw.viewport ?? [1280, 720],
179
+ scope: raw.scope ?? "canvas",
180
+ canvas: raw.canvas ?? "canvas",
181
+ fixture: raw.fixture ?? `${url.pathname}${url.hash}`,
182
+ renderer: raw.renderer,
183
+ backend: raw.backend,
184
+ conditions: raw.conditions ?? {},
185
+ tolerance: raw.tolerance ?? {},
186
+ clients: raw.clients ?? [{ name: "main" }],
187
+ shots: raw.shots ?? [],
188
+ steps: raw.steps ?? [],
189
+ scenarios: raw.scenarios ?? [],
190
+ thresholds: raw.thresholds ?? {},
191
+ timeoutMs: raw.timeoutMs ?? 6e4
192
+ };
193
+ }
194
+ function referenceErrors(plan) {
195
+ const errors = [];
196
+ const clients = new Set((plan.clients ?? [{ name: "main" }]).map((c) => c.name));
197
+ const problem = (message, path) => errors.push(new ShardError("verify/invalid-json", message, { path }));
198
+ const unique = (names, path, what) => {
199
+ const seen = /* @__PURE__ */ new Set();
200
+ names.forEach((name, i) => {
201
+ if (seen.has(name)) problem(`Two ${what} are named "${name}"`, `${path}/${i}/name`);
202
+ seen.add(name);
203
+ });
204
+ };
205
+ unique(
206
+ (plan.clients ?? []).map((c) => c.name),
207
+ "/clients",
208
+ "clients"
209
+ );
210
+ unique(
211
+ (plan.shots ?? []).map((s) => s.name),
212
+ "/shots",
213
+ "shots"
214
+ );
215
+ unique(
216
+ (plan.steps ?? []).map((s) => s.name),
217
+ "/steps",
218
+ "steps"
219
+ );
220
+ unique(
221
+ (plan.scenarios ?? []).map((s) => s.name),
222
+ "/scenarios",
223
+ "scenarios"
224
+ );
225
+ const known = (names, path) => {
226
+ names?.forEach((name, i) => {
227
+ if (!clients.has(name)) problem(`No client is named "${name}"`, `${path}/${i}`);
228
+ });
229
+ };
230
+ const steps = (list, path) => {
231
+ list?.forEach((s, i) => {
232
+ known(s.clients, `${path}/${i}/clients`);
233
+ known(Object.keys(s.expect ?? {}), `${path}/${i}/expect`);
234
+ });
235
+ };
236
+ steps(plan.steps, "/steps");
237
+ plan.scenarios?.forEach((s, i) => {
238
+ known(s.clients, `/scenarios/${i}/clients`);
239
+ steps(s.steps, `/scenarios/${i}/steps`);
240
+ });
241
+ return errors;
242
+ }
243
+ function shotId(browser, client, shot2, dpr) {
244
+ return `${browser}/${client}/${shot2}@${dpr}x`;
245
+ }
246
+ export {
247
+ capturePlanJsonSchema,
248
+ parsePlan,
249
+ shotId
250
+ };
@@ -0,0 +1,93 @@
1
+ import type { JsonSchema, SpanStats } from '@aethervtt/shard-core';
2
+ import { ShardError } from '@aethervtt/shard-core';
3
+ /**
4
+ * One measured run of one scenario on one renderer (0062). Shard's `metrics.record` writes it, and
5
+ * so can any other renderer's page (Aether's three.js path) by the same definitions. Times are ms,
6
+ * sizes bytes.
7
+ */
8
+ export interface PerfRecord {
9
+ /** 2 adds `breakdown` (0074); version 1 records still read. */
10
+ version: 1 | 2;
11
+ /** `shard@<sha>`, `three@0.160.1`: the part before `@` names the renderer in thresholds. */
12
+ renderer: string;
13
+ fixture: string;
14
+ scenario: string;
15
+ device: PerfDevice;
16
+ /** The render scale every frame of the window ran at (0051). A pinned run has min = max. */
17
+ renderScale: {
18
+ mode: 'fixed' | 'auto' | 'none';
19
+ min: number;
20
+ max: number;
21
+ };
22
+ /**
23
+ * `total`: navigation start to `app.init()` resolved; `modules`: navigation start to `init()`
24
+ * called. The rest are wall time that kind of work was in flight until the first usable frame:
25
+ * the GPU device request, pipeline compiles, asset loads. They overlap each other.
26
+ */
27
+ coldStart: {
28
+ total: number;
29
+ modules: number;
30
+ device: number;
31
+ pipelines: number;
32
+ assets: number;
33
+ };
34
+ /** Navigation start to the first frame presented after `app.markUsable()`. */
35
+ firstUsableFrame: number;
36
+ /** A host write (`app.trace`) to the frame that shows it being presented. */
37
+ patchToFrame: {
38
+ p50: number;
39
+ p95: number;
40
+ n: number;
41
+ };
42
+ /** Intervals between consecutive frames over the window; GPU time where timestamps exist. */
43
+ frameTime: {
44
+ p50: number;
45
+ p95: number;
46
+ p99: number;
47
+ n: number;
48
+ gpuP95?: number;
49
+ };
50
+ longTasks: {
51
+ count: number;
52
+ totalMs: number;
53
+ maxMs: number;
54
+ };
55
+ /** Every buffer and texture the renderer made, by what it's for. */
56
+ gpuMemory: {
57
+ bytes: number;
58
+ byCategory: Record<string, number>;
59
+ };
60
+ /** The page's downloads: the document, scripts, WASM, and assets. */
61
+ download: {
62
+ transferred: number;
63
+ decoded: number;
64
+ };
65
+ /**
66
+ * Where the time went (0074, version 2): the 10 spans with the highest p95, CPU (systems,
67
+ * schedules, render encodes, workers) and GPU passes, over the profiler's window.
68
+ */
69
+ breakdown?: {
70
+ cpu: SpanStats[];
71
+ gpu: SpanStats[];
72
+ };
73
+ }
74
+ export interface PerfDevice {
75
+ ua: string;
76
+ /** The adapter: vendor, architecture, description, whichever the browser reports. */
77
+ gpu: string;
78
+ dpr: number;
79
+ /** CSS pixels. */
80
+ viewport: [number, number];
81
+ }
82
+ /** JSON Schema for `PerfRecord` (shipped as `.shard/schemas/perf-record.schema.json`). */
83
+ export declare function perfRecordJsonSchema(): JsonSchema;
84
+ /**
85
+ * Checks `value` against a JSON Schema, for the keywords Shard's own schemas use: type, const,
86
+ * enum, required, properties, additionalProperties, items, min/maxItems, minLength, minimum,
87
+ * exclusiveMinimum. Returns every problem, each with its JSON path.
88
+ */
89
+ export declare function validateJson(schema: JsonSchema, value: unknown, path?: string): ShardError[];
90
+ /** Reads a record, or throws `verify/invalid-record` listing every problem. */
91
+ export declare function parsePerfRecord(json: unknown, file?: string): PerfRecord;
92
+ /** The renderer's name without its version: `three@0.160.1` → `three`. */
93
+ export declare function rendererName(record: Pick<PerfRecord, 'renderer'>): string;
package/dist/record.js ADDED
@@ -0,0 +1,226 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ const ms = { type: "number", minimum: 0 };
3
+ const count = { type: "integer", minimum: 0 };
4
+ const spanStats = {
5
+ type: "array",
6
+ items: {
7
+ type: "object",
8
+ additionalProperties: false,
9
+ required: ["span", "track", "calls", "total", "p50", "p95", "max"],
10
+ properties: {
11
+ span: { type: "string", minLength: 1 },
12
+ track: { enum: ["main", "gpu", "async", "gc", "worker"] },
13
+ calls: count,
14
+ total: ms,
15
+ p50: ms,
16
+ p95: ms,
17
+ max: ms
18
+ }
19
+ }
20
+ };
21
+ function perfRecordJsonSchema() {
22
+ return {
23
+ $schema: "https://json-schema.org/draft/2020-12/schema",
24
+ $id: "https://shard.dev/schemas/perf-record.schema.json",
25
+ title: "Shard performance record",
26
+ description: "One measured run of one scenario on one renderer (spec 0062). Times are milliseconds, sizes bytes.",
27
+ type: "object",
28
+ additionalProperties: false,
29
+ required: [
30
+ "version",
31
+ "renderer",
32
+ "fixture",
33
+ "scenario",
34
+ "device",
35
+ "renderScale",
36
+ "coldStart",
37
+ "firstUsableFrame",
38
+ "patchToFrame",
39
+ "frameTime",
40
+ "longTasks",
41
+ "gpuMemory",
42
+ "download"
43
+ ],
44
+ properties: {
45
+ $schema: { type: "string" },
46
+ version: { enum: [1, 2] },
47
+ renderer: {
48
+ type: "string",
49
+ minLength: 1,
50
+ description: "'shard@<sha>' or 'three@0.160.1': the name before '@' is what thresholds use."
51
+ },
52
+ fixture: { type: "string", minLength: 1 },
53
+ scenario: { type: "string", minLength: 1 },
54
+ device: {
55
+ type: "object",
56
+ additionalProperties: false,
57
+ required: ["ua", "gpu", "dpr", "viewport"],
58
+ properties: {
59
+ ua: { type: "string" },
60
+ gpu: { type: "string" },
61
+ dpr: { type: "number", exclusiveMinimum: 0 },
62
+ viewport: {
63
+ type: "array",
64
+ items: { type: "number", minimum: 0 },
65
+ minItems: 2,
66
+ maxItems: 2,
67
+ description: "Width and height in CSS pixels."
68
+ }
69
+ }
70
+ },
71
+ renderScale: {
72
+ type: "object",
73
+ additionalProperties: false,
74
+ required: ["mode", "min", "max"],
75
+ description: "The render scale over the window. Pinned runs have min = max.",
76
+ properties: {
77
+ mode: { enum: ["fixed", "auto", "none"] },
78
+ min: { type: "number", exclusiveMinimum: 0 },
79
+ max: { type: "number", exclusiveMinimum: 0 }
80
+ }
81
+ },
82
+ coldStart: {
83
+ type: "object",
84
+ additionalProperties: false,
85
+ required: ["total", "modules", "device", "pipelines", "assets"],
86
+ properties: { total: ms, modules: ms, device: ms, pipelines: ms, assets: ms }
87
+ },
88
+ firstUsableFrame: ms,
89
+ patchToFrame: {
90
+ type: "object",
91
+ additionalProperties: false,
92
+ required: ["p50", "p95", "n"],
93
+ properties: { p50: ms, p95: ms, n: count }
94
+ },
95
+ frameTime: {
96
+ type: "object",
97
+ additionalProperties: false,
98
+ required: ["p50", "p95", "p99", "n"],
99
+ properties: { p50: ms, p95: ms, p99: ms, n: count, gpuP95: ms }
100
+ },
101
+ longTasks: {
102
+ type: "object",
103
+ additionalProperties: false,
104
+ required: ["count", "totalMs", "maxMs"],
105
+ properties: { count, totalMs: ms, maxMs: ms }
106
+ },
107
+ gpuMemory: {
108
+ type: "object",
109
+ additionalProperties: false,
110
+ required: ["bytes", "byCategory"],
111
+ properties: { bytes: count, byCategory: { type: "object", additionalProperties: count } }
112
+ },
113
+ download: {
114
+ type: "object",
115
+ additionalProperties: false,
116
+ required: ["transferred", "decoded"],
117
+ properties: { transferred: count, decoded: count }
118
+ },
119
+ breakdown: {
120
+ type: "object",
121
+ additionalProperties: false,
122
+ required: ["cpu", "gpu"],
123
+ description: "Version 2 (spec 0074): the 10 CPU and 10 GPU spans with the highest p95 over the window.",
124
+ properties: { cpu: spanStats, gpu: spanStats }
125
+ }
126
+ }
127
+ };
128
+ }
129
+ function validateJson(schema, value, path = "") {
130
+ const errors = [];
131
+ check(schema, value, path, errors);
132
+ return errors;
133
+ }
134
+ function check(schema, value, path, out) {
135
+ const fail = (message) => out.push(new ShardError("verify/invalid-json", message, { path: path || "/" }));
136
+ if ("const" in schema && value !== schema.const) {
137
+ return void fail(`Expected ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
138
+ }
139
+ const options = schema.enum;
140
+ if (options && !options.includes(value)) {
141
+ return void fail(`Expected one of ${options.map((o) => JSON.stringify(o)).join(", ")}`);
142
+ }
143
+ const type = schema.type;
144
+ if (type && !isType(value, type)) return void fail(`Expected ${type}, got ${typeName(value)}`);
145
+ if (typeof value === "number") {
146
+ const min = schema.minimum;
147
+ const above = schema.exclusiveMinimum;
148
+ if (min !== void 0 && value < min) fail(`Expected at least ${min}, got ${value}`);
149
+ if (above !== void 0 && value <= above) fail(`Expected more than ${above}, got ${value}`);
150
+ }
151
+ if (typeof value === "string" && value.length < (schema.minLength ?? 0)) {
152
+ fail("Expected a non-empty string");
153
+ }
154
+ if (Array.isArray(value)) {
155
+ const min = schema.minItems;
156
+ const max = schema.maxItems;
157
+ if (min !== void 0 && value.length < min) fail(`Expected at least ${min} items`);
158
+ if (max !== void 0 && value.length > max) fail(`Expected at most ${max} items`);
159
+ const items = schema.items;
160
+ if (items) for (let i = 0; i < value.length; i++) check(items, value[i], `${path}/${i}`, out);
161
+ }
162
+ if (type !== "object" || !isType(value, "object")) return;
163
+ const object = value;
164
+ for (const key of schema.required ?? []) {
165
+ if (!(key in object))
166
+ out.push(
167
+ new ShardError("verify/invalid-json", `Missing "${key}"`, { path: `${path}/${key}` })
168
+ );
169
+ }
170
+ const properties = schema.properties ?? {};
171
+ const extra = schema.additionalProperties;
172
+ for (const [key, item] of Object.entries(object)) {
173
+ const own = properties[key];
174
+ if (own) check(own, item, `${path}/${key}`, out);
175
+ else if (extra === false) {
176
+ out.push(
177
+ new ShardError("verify/invalid-json", `Unknown field "${key}"`, { path: `${path}/${key}` })
178
+ );
179
+ } else if (extra && typeof extra === "object") check(extra, item, `${path}/${key}`, out);
180
+ }
181
+ }
182
+ function isType(value, type) {
183
+ switch (type) {
184
+ case "object":
185
+ return typeof value === "object" && value !== null && !Array.isArray(value);
186
+ case "array":
187
+ return Array.isArray(value);
188
+ case "integer":
189
+ return Number.isInteger(value);
190
+ case "number":
191
+ return typeof value === "number" && Number.isFinite(value);
192
+ default:
193
+ return typeof value === type;
194
+ }
195
+ }
196
+ function typeName(value) {
197
+ if (value === null) return "null";
198
+ if (Array.isArray(value)) return "array";
199
+ if (typeof value === "number" && !Number.isFinite(value)) return String(value);
200
+ return typeof value;
201
+ }
202
+ function parsePerfRecord(json, file) {
203
+ const errors = validateJson(perfRecordJsonSchema(), json);
204
+ if (errors.length > 0) {
205
+ throw new ShardError(
206
+ "verify/invalid-record",
207
+ `${file ?? "The record"} isn't a valid performance record: ${errors[0].message} at ${errors[0].path}`,
208
+ {
209
+ path: file,
210
+ details: errors,
211
+ hint: "Records follow .shard/schemas/perf-record.schema.json."
212
+ }
213
+ );
214
+ }
215
+ return json;
216
+ }
217
+ function rendererName(record) {
218
+ const at = record.renderer.indexOf("@");
219
+ return at === -1 ? record.renderer : record.renderer.slice(0, at);
220
+ }
221
+ export {
222
+ parsePerfRecord,
223
+ perfRecordJsonSchema,
224
+ rendererName,
225
+ validateJson
226
+ };
@@ -0,0 +1,20 @@
1
+ import type { Approval } from './run.js';
2
+ /** One shot's line in the compare report. Image paths are relative to the report. */
3
+ export interface ReportShot {
4
+ id: string;
5
+ status: 'pass' | 'fail' | 'new';
6
+ reason?: string;
7
+ approved?: string;
8
+ capture: string;
9
+ heatmap?: string;
10
+ share?: number;
11
+ maxDeltaE?: number;
12
+ ssim?: number;
13
+ /** The latest approval of this shot, if any. */
14
+ approval?: Approval;
15
+ }
16
+ /**
17
+ * The compare report (0062): per shot, the approved image, the capture, and a heatmap of what
18
+ * changed, with the numbers and the reason it was last approved. Failures first.
19
+ */
20
+ export declare function renderReport(title: string, shots: readonly ReportShot[]): string;
package/dist/report.js ADDED
@@ -0,0 +1,43 @@
1
+ const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
2
+ function renderReport(title, shots) {
3
+ const order = { fail: 0, new: 1, pass: 2 };
4
+ const sorted = [...shots].sort(
5
+ (a, b) => order[a.status] - order[b.status] || a.id.localeCompare(b.id)
6
+ );
7
+ const count = (s) => shots.filter((x) => x.status === s).length;
8
+ const figure = (label, src) => src ? `<figure><img src="${escapeHtml(src)}" alt="${label}" loading="lazy"><figcaption>${label}</figcaption></figure>` : `<figure class="none"><div>none</div><figcaption>${label}</figcaption></figure>`;
9
+ const rows = sorted.map((s) => {
10
+ const numbers = s.share === void 0 ? "" : `<span>${(s.share * 100).toFixed(3)}% changed</span><span>max \u0394E ${s.maxDeltaE.toFixed(1)}</span><span>SSIM ${s.ssim.toFixed(4)}</span>`;
11
+ const approval = s.approval ? `<p class="approval">Approved ${escapeHtml(s.approval.date.slice(0, 10))} by ${escapeHtml(s.approval.by)}: ${escapeHtml(s.approval.reason)}</p>` : "";
12
+ return `<section class="${s.status}">
13
+ <h2><span class="status">${s.status}</span> ${escapeHtml(s.id)}</h2>
14
+ <p class="numbers">${numbers}${s.reason ? `<span class="reason">${escapeHtml(s.reason)}</span>` : ""}</p>${approval}
15
+ <div class="row">${figure("approved", s.approved)}${figure("capture", s.capture)}${figure("changes", s.heatmap)}</div>
16
+ </section>`;
17
+ });
18
+ return `<!doctype html>
19
+ <html lang="en"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
20
+ <style>
21
+ body { font: 14px/1.4 system-ui, sans-serif; margin: 24px; background: #15171a; color: #d8dade; }
22
+ h1 { font-size: 20px; } h2 { font-size: 15px; margin: 0 0 4px; font-family: ui-monospace, monospace; }
23
+ section { border-left: 4px solid #3a8f5c; padding: 8px 12px; margin: 16px 0; background: #1c1f23; }
24
+ section.fail { border-color: #d0463b; } section.new { border-color: #d8a93b; }
25
+ .status { text-transform: uppercase; font-size: 11px; padding: 1px 6px; border-radius: 3px; background: #333; }
26
+ .fail .status { background: #7a2620; } .new .status { background: #6d5620; }
27
+ .numbers span { margin-right: 14px; color: #aab; } .reason { color: #f0b0a8 !important; }
28
+ .approval { color: #9fb8a6; margin: 2px 0; }
29
+ .row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
30
+ figure { margin: 0; } figure img { width: 100%; image-rendering: pixelated;
31
+ background: repeating-conic-gradient(#2a2d31 0 25%, #202226 0 50%) 0 0 / 16px 16px; }
32
+ figure.none div { aspect-ratio: 16 / 9; display: grid; place-items: center; background: #202226; color: #667; }
33
+ figcaption { color: #889; font-size: 12px; }
34
+ </style></head><body>
35
+ <h1>${escapeHtml(title)}</h1>
36
+ <p>${count("fail")} changed, ${count("new")} new, ${count("pass")} unchanged.</p>
37
+ ${rows.join("\n")}
38
+ </body></html>
39
+ `;
40
+ }
41
+ export {
42
+ renderReport
43
+ };
package/dist/run.d.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type { Tolerance } from './compare.js';
2
+ import type { CheckFailure } from './expect.js';
3
+ import type { CaptureScope } from './plan.js';
4
+ /** What `shard capture` wrote: `<out>/manifest.json`, next to the PNGs and records. */
5
+ export interface CaptureManifest {
6
+ version: 1;
7
+ url: string;
8
+ fixture: string;
9
+ /** ISO time the run started. */
10
+ date: string;
11
+ shots: CapturedShot[];
12
+ /** Record files, relative to the manifest. */
13
+ records: string[];
14
+ /** Steps that ran and the checks that failed (a run with failures exits 1). */
15
+ steps: {
16
+ name: string;
17
+ browser: string;
18
+ dpr: number;
19
+ failures: CheckFailure[];
20
+ }[];
21
+ /** Browsers the plan named that couldn't run it (no WebGPU, not installed), and why. */
22
+ skipped: {
23
+ browser: string;
24
+ reason: string;
25
+ }[];
26
+ }
27
+ export interface CapturedShot {
28
+ /** `chromium/main/map-close@2x`: also the PNG's path under the run, less `.png`. */
29
+ id: string;
30
+ browser: string;
31
+ client: string;
32
+ shot: string;
33
+ dpr: number;
34
+ scope: CaptureScope;
35
+ width: number;
36
+ height: number;
37
+ /** SHA-256 of the PNG file. */
38
+ hash: string;
39
+ tolerance: Tolerance;
40
+ }
41
+ /** `approvals.json` in the approved directory: why each approved image looks the way it does. */
42
+ export interface ApprovalsFile {
43
+ version: 1;
44
+ approvals: Approval[];
45
+ }
46
+ export interface Approval {
47
+ shot: string;
48
+ /** SHA-256 of the approved PNG. */
49
+ hash: string;
50
+ reason: string;
51
+ by: string;
52
+ /** ISO time. */
53
+ date: string;
54
+ }
55
+ /** Appends an approval. Refuses one without a reason: every visible change is explained. */
56
+ export declare function addApproval(file: ApprovalsFile | undefined, approval: Approval): ApprovalsFile;