@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/LICENSE +21 -0
- package/README.md +2 -2
- package/dist/compare.d.ts +45 -0
- package/dist/compare.js +213 -0
- package/dist/expect.d.ts +14 -0
- package/dist/expect.js +70 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +43 -0
- package/dist/metrics/index.d.ts +1 -0
- package/dist/metrics/index.js +12 -0
- package/dist/metrics/metrics.d.ts +83 -0
- package/dist/metrics/metrics.js +246 -0
- package/dist/node/capture.d.ts +48 -0
- package/dist/node/capture.js +270 -0
- package/dist/node/files.d.ts +40 -0
- package/dist/node/files.js +159 -0
- package/dist/node/index.d.ts +3 -0
- package/dist/node/index.js +30 -0
- package/dist/node/png.d.ts +7 -0
- package/dist/node/png.js +50 -0
- package/dist/page-api.d.ts +32 -0
- package/dist/page-api.js +0 -0
- package/dist/page.d.ts +17 -0
- package/dist/page.js +104 -0
- package/dist/plan.d.ts +132 -0
- package/dist/plan.js +250 -0
- package/dist/record.d.ts +93 -0
- package/dist/record.js +226 -0
- package/dist/report.d.ts +20 -0
- package/dist/report.js +43 -0
- package/dist/run.d.ts +56 -0
- package/dist/run.js +19 -0
- package/dist/stats.d.ts +26 -0
- package/dist/stats.js +56 -0
- package/dist/thresholds.d.ts +51 -0
- package/dist/thresholds.js +156 -0
- package/package.json +49 -4
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { copyFile, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
4
|
+
import { compareImages, diffHeatmap } from "../compare.js";
|
|
5
|
+
import { parsePlan } from "../plan.js";
|
|
6
|
+
import { parsePerfRecord } from "../record.js";
|
|
7
|
+
import { renderReport } from "../report.js";
|
|
8
|
+
import { addApproval } from "../run.js";
|
|
9
|
+
import { checkThresholds } from "../thresholds.js";
|
|
10
|
+
import { decodePng, encodePng, sha256 } from "./png.js";
|
|
11
|
+
async function readJson(file, what) {
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
text = await readFile(file, "utf8");
|
|
15
|
+
} catch {
|
|
16
|
+
throw new ShardError("verify/not-found", `No ${what} at ${file}`, { path: file });
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(text);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
throw new ShardError(
|
|
22
|
+
"verify/invalid-json",
|
|
23
|
+
`${file} isn't valid JSON: ${err.message}`,
|
|
24
|
+
{
|
|
25
|
+
path: file
|
|
26
|
+
}
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function readPlan(file) {
|
|
31
|
+
return parsePlan(await readJson(file, "capture plan"), file);
|
|
32
|
+
}
|
|
33
|
+
async function readManifest(captures) {
|
|
34
|
+
const json = await readJson(join(captures, "manifest.json"), "capture run");
|
|
35
|
+
return json;
|
|
36
|
+
}
|
|
37
|
+
async function readApprovals(approved) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(await readFile(join(approved, "approvals.json"), "utf8"));
|
|
40
|
+
} catch {
|
|
41
|
+
return void 0;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function exists(file) {
|
|
45
|
+
return stat(file).then(
|
|
46
|
+
() => true,
|
|
47
|
+
() => false
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
async function compareRun(captures, approved, options = {}) {
|
|
51
|
+
const manifest = await readManifest(captures);
|
|
52
|
+
const approvals = await readApprovals(approved);
|
|
53
|
+
const reportFile = resolve(options.report ?? join(captures, "report.html"));
|
|
54
|
+
const base = dirname(reportFile);
|
|
55
|
+
const link = (file) => relative(base, file).split("\\").join("/");
|
|
56
|
+
const shots = [];
|
|
57
|
+
for (const shot of manifest.shots) {
|
|
58
|
+
const captureFile = join(captures, `${shot.id}.png`);
|
|
59
|
+
const approvedFile = join(approved, `${shot.id}.png`);
|
|
60
|
+
const approval = approvals?.approvals.filter((a) => a.shot === shot.id).at(-1);
|
|
61
|
+
const entry = {
|
|
62
|
+
id: shot.id,
|
|
63
|
+
hash: shot.hash,
|
|
64
|
+
status: "new",
|
|
65
|
+
capture: link(captureFile),
|
|
66
|
+
...approval && { approval }
|
|
67
|
+
};
|
|
68
|
+
if (!await exists(approvedFile)) {
|
|
69
|
+
shots.push({ ...entry, reason: "no approved image" });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const approvedBytes = new Uint8Array(await readFile(approvedFile));
|
|
73
|
+
entry.approved = link(approvedFile);
|
|
74
|
+
if (sha256(approvedBytes) === shot.hash) {
|
|
75
|
+
shots.push({ ...entry, status: "pass", share: 0, maxDeltaE: 0, ssim: 1 });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const before = await decodePng(approvedBytes);
|
|
79
|
+
const after = await decodePng(new Uint8Array(await readFile(captureFile)));
|
|
80
|
+
let diff;
|
|
81
|
+
try {
|
|
82
|
+
diff = compareImages(before, after, shot.tolerance);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
shots.push({ ...entry, status: "fail", reason: err.message });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const heatmapFile = join(captures, "diff", `${shot.id}.png`);
|
|
88
|
+
await mkdir(dirname(heatmapFile), { recursive: true });
|
|
89
|
+
await writeFile(heatmapFile, encodePng(diffHeatmap(before, diff), before.width, before.height));
|
|
90
|
+
shots.push({
|
|
91
|
+
...entry,
|
|
92
|
+
status: diff.pass ? "pass" : "fail",
|
|
93
|
+
reason: diff.reason,
|
|
94
|
+
heatmap: link(heatmapFile),
|
|
95
|
+
share: diff.share,
|
|
96
|
+
maxDeltaE: diff.maxDeltaE,
|
|
97
|
+
ssim: diff.ssim
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
await mkdir(base, { recursive: true });
|
|
101
|
+
await writeFile(reportFile, renderReport(`Captures of ${manifest.fixture}`, shots));
|
|
102
|
+
return { pass: shots.every((s) => s.status === "pass"), shots, report: reportFile };
|
|
103
|
+
}
|
|
104
|
+
async function approveShot(options) {
|
|
105
|
+
const { captures, approved, shot } = options;
|
|
106
|
+
const approval = {
|
|
107
|
+
shot,
|
|
108
|
+
hash: "",
|
|
109
|
+
reason: options.reason ?? "",
|
|
110
|
+
by: options.by,
|
|
111
|
+
date: (options.date ?? /* @__PURE__ */ new Date()).toISOString()
|
|
112
|
+
};
|
|
113
|
+
const approvals = addApproval(await readApprovals(approved), approval);
|
|
114
|
+
const manifest = await readManifest(captures);
|
|
115
|
+
const captured = manifest.shots.find((s) => s.id === shot);
|
|
116
|
+
if (!captured) {
|
|
117
|
+
throw new ShardError("verify/unknown-shot", `The run in ${captures} has no shot "${shot}"`, {
|
|
118
|
+
hint: `Its shots: ${manifest.shots.map((s) => s.id).join(", ") || "none"}.`
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const target = join(approved, `${shot}.png`);
|
|
122
|
+
await mkdir(dirname(target), { recursive: true });
|
|
123
|
+
await copyFile(join(captures, `${shot}.png`), target);
|
|
124
|
+
const recorded = approvals.approvals.at(-1);
|
|
125
|
+
recorded.hash = captured.hash;
|
|
126
|
+
await writeFile(join(approved, "approvals.json"), `${JSON.stringify(approvals, null, 2)}
|
|
127
|
+
`);
|
|
128
|
+
return recorded;
|
|
129
|
+
}
|
|
130
|
+
async function readRecords(paths) {
|
|
131
|
+
const records = [];
|
|
132
|
+
const visit = async (path) => {
|
|
133
|
+
const info = await stat(path).catch(() => void 0);
|
|
134
|
+
if (!info) throw new ShardError("verify/not-found", `No records at ${path}`, { path });
|
|
135
|
+
if (info.isDirectory()) {
|
|
136
|
+
for (const entry of (await readdir(path)).sort()) {
|
|
137
|
+
const child = join(path, entry);
|
|
138
|
+
if (entry.endsWith(".json") || (await stat(child)).isDirectory()) await visit(child);
|
|
139
|
+
}
|
|
140
|
+
} else if (!path.endsWith("manifest.json")) {
|
|
141
|
+
records.push(parsePerfRecord(await readJson(path, "record"), path));
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
for (const path of paths) await visit(path);
|
|
145
|
+
return records;
|
|
146
|
+
}
|
|
147
|
+
async function perfCheck(planFile, paths) {
|
|
148
|
+
const plan = await readPlan(planFile);
|
|
149
|
+
const records = await readRecords(paths);
|
|
150
|
+
return { ...checkThresholds(records, plan.thresholds), records: records.length };
|
|
151
|
+
}
|
|
152
|
+
export {
|
|
153
|
+
approveShot,
|
|
154
|
+
compareRun,
|
|
155
|
+
perfCheck,
|
|
156
|
+
readManifest,
|
|
157
|
+
readPlan,
|
|
158
|
+
readRecords
|
|
159
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { browserLaunch, type CaptureOptions, type CaptureRun, clientUrl, ISOLATION_HEADERS, runCapture, } from './capture.js';
|
|
2
|
+
export { approveShot, type CompareResult, type CompareRun, compareRun, perfCheck, readManifest, readPlan, readRecords, } from './files.js';
|
|
3
|
+
export { decodePng, encodePng, sha256 } from './png.js';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {
|
|
2
|
+
browserLaunch,
|
|
3
|
+
clientUrl,
|
|
4
|
+
ISOLATION_HEADERS,
|
|
5
|
+
runCapture
|
|
6
|
+
} from "./capture.js";
|
|
7
|
+
import {
|
|
8
|
+
approveShot,
|
|
9
|
+
compareRun,
|
|
10
|
+
perfCheck,
|
|
11
|
+
readManifest,
|
|
12
|
+
readPlan,
|
|
13
|
+
readRecords
|
|
14
|
+
} from "./files.js";
|
|
15
|
+
import { decodePng, encodePng, sha256 } from "./png.js";
|
|
16
|
+
export {
|
|
17
|
+
ISOLATION_HEADERS,
|
|
18
|
+
approveShot,
|
|
19
|
+
browserLaunch,
|
|
20
|
+
clientUrl,
|
|
21
|
+
compareRun,
|
|
22
|
+
decodePng,
|
|
23
|
+
encodePng,
|
|
24
|
+
perfCheck,
|
|
25
|
+
readManifest,
|
|
26
|
+
readPlan,
|
|
27
|
+
readRecords,
|
|
28
|
+
runCapture,
|
|
29
|
+
sha256
|
|
30
|
+
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RgbaImage } from '../compare.js';
|
|
2
|
+
/** RGBA8 PNG, filter none: diff heatmaps. */
|
|
3
|
+
export declare function encodePng(rgba: Uint8Array, width: number, height: number): Uint8Array;
|
|
4
|
+
/** A PNG as straight-alpha RGBA8. */
|
|
5
|
+
export declare function decodePng(png: Uint8Array): Promise<RgbaImage>;
|
|
6
|
+
/** SHA-256 of a file's bytes, hex. */
|
|
7
|
+
export declare function sha256(bytes: Uint8Array): string;
|
package/dist/node/png.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { crc32, deflateSync } from "node:zlib";
|
|
3
|
+
import { decodePngImage } from "@aethervtt/shard-texture";
|
|
4
|
+
function encodePng(rgba, width, height) {
|
|
5
|
+
const stride = width * 4;
|
|
6
|
+
const raw = new Uint8Array((stride + 1) * height);
|
|
7
|
+
for (let y = 0; y < height; y++) {
|
|
8
|
+
raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
|
9
|
+
}
|
|
10
|
+
const chunk = (type, data) => {
|
|
11
|
+
const out2 = new Uint8Array(12 + data.length);
|
|
12
|
+
const view2 = new DataView(out2.buffer);
|
|
13
|
+
view2.setUint32(0, data.length);
|
|
14
|
+
for (let i = 0; i < 4; i++) out2[4 + i] = type.charCodeAt(i);
|
|
15
|
+
out2.set(data, 8);
|
|
16
|
+
view2.setUint32(8 + data.length, crc32(out2.subarray(4, 8 + data.length)));
|
|
17
|
+
return out2;
|
|
18
|
+
};
|
|
19
|
+
const header = new Uint8Array(13);
|
|
20
|
+
const view = new DataView(header.buffer);
|
|
21
|
+
view.setUint32(0, width);
|
|
22
|
+
view.setUint32(4, height);
|
|
23
|
+
header[8] = 8;
|
|
24
|
+
header[9] = 6;
|
|
25
|
+
const parts = [
|
|
26
|
+
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
27
|
+
chunk("IHDR", header),
|
|
28
|
+
chunk("IDAT", deflateSync(raw)),
|
|
29
|
+
chunk("IEND", new Uint8Array(0))
|
|
30
|
+
];
|
|
31
|
+
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
|
|
32
|
+
let at = 0;
|
|
33
|
+
for (const p of parts) {
|
|
34
|
+
out.set(p, at);
|
|
35
|
+
at += p.length;
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
async function decodePng(png) {
|
|
40
|
+
const image = await decodePngImage(png);
|
|
41
|
+
return { width: image.width, height: image.height, data: image.data };
|
|
42
|
+
}
|
|
43
|
+
function sha256(bytes) {
|
|
44
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
decodePng,
|
|
48
|
+
encodePng,
|
|
49
|
+
sha256
|
|
50
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { RecordMeta } from './metrics/metrics.js';
|
|
2
|
+
import type { PlanConditions } from './plan.js';
|
|
3
|
+
import type { PerfRecord } from './record.js';
|
|
4
|
+
/** `window.__shardCapture`: what `shard capture` drives (0062). Types only: Node imports it too. */
|
|
5
|
+
export interface CapturePage {
|
|
6
|
+
apply(state: Record<string, unknown>): Promise<void>;
|
|
7
|
+
/** Resolves once every app has nothing loading and its last frame is presented. */
|
|
8
|
+
idle(timeoutMs?: number): Promise<void>;
|
|
9
|
+
/** Runs a host step; with `trace`, stamps it and reports the latency to the frame showing it. */
|
|
10
|
+
step(name: string, args?: unknown, trace?: boolean): Promise<{
|
|
11
|
+
result: unknown;
|
|
12
|
+
latencyMs?: number;
|
|
13
|
+
}>;
|
|
14
|
+
probe(): Promise<unknown>;
|
|
15
|
+
conditions(conditions: PlanConditions): Promise<void>;
|
|
16
|
+
/** A PNG of a canvas's own pixels, alpha kept, as base64. */
|
|
17
|
+
snapshot(selector: string): Promise<{
|
|
18
|
+
png: string;
|
|
19
|
+
width: number;
|
|
20
|
+
height: number;
|
|
21
|
+
}>;
|
|
22
|
+
/** Starts a new measurement window on every app. */
|
|
23
|
+
reset(): void;
|
|
24
|
+
/** The first app's record (the one with metrics). */
|
|
25
|
+
record(meta: RecordMeta): PerfRecord;
|
|
26
|
+
}
|
|
27
|
+
declare global {
|
|
28
|
+
interface Window {
|
|
29
|
+
__shardCapture?: CapturePage;
|
|
30
|
+
__shardReady?: boolean;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/dist/page-api.js
ADDED
|
File without changes
|
package/dist/page.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type App } from '@aethervtt/shard-runtime';
|
|
2
|
+
import type { CapturePage } from './page-api.js';
|
|
3
|
+
export type { CapturePage } from './page-api.js';
|
|
4
|
+
/** What a host page gives the capture tool (0062). Every part is optional. */
|
|
5
|
+
export interface CaptureHost {
|
|
6
|
+
/** Applies a shot's state: the camera, the active view, scene toggles. */
|
|
7
|
+
apply?(state: Record<string, unknown>): void | Promise<void>;
|
|
8
|
+
/** Host actions a plan's steps run by name: move a token, switch scenes, reconnect. */
|
|
9
|
+
steps?: Record<string, (args: unknown) => unknown>;
|
|
10
|
+
/** What checks read after a step: entity sets, owner and mirror counts. JSON only. */
|
|
11
|
+
probe?(): unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Installs `window.__shardCapture` for `shard capture`, and sets `window.__shardReady` once every
|
|
15
|
+
* app has presented its first usable frame (the host calls `app.markUsable()`).
|
|
16
|
+
*/
|
|
17
|
+
export declare function installCapturePage(apps: App | readonly App[], host?: CaptureHost): CapturePage;
|
package/dist/page.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
2
|
+
import { Gpu, RenderScale } from "@aethervtt/shard-render";
|
|
3
|
+
import { FrameDemand, LOADING_DEMAND } from "@aethervtt/shard-runtime";
|
|
4
|
+
import { MetricsResource } from "./metrics/metrics.js";
|
|
5
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
6
|
+
function loading(app) {
|
|
7
|
+
const gpu = app.world.tryResource(Gpu);
|
|
8
|
+
return app.world.resource(FrameDemand).isHeld(LOADING_DEMAND) || (gpu?.pipelines.pending ?? 0) > 0;
|
|
9
|
+
}
|
|
10
|
+
function settled(app) {
|
|
11
|
+
const demand = app.world.resource(FrameDemand);
|
|
12
|
+
return demand.mode !== "on-demand" || app.world.asleep && !demand.active;
|
|
13
|
+
}
|
|
14
|
+
async function idleApp(app, deadline) {
|
|
15
|
+
for (; ; ) {
|
|
16
|
+
if (performance.now() > deadline) {
|
|
17
|
+
const held = app.world.resource(FrameDemand).held().join(", ") || "nothing";
|
|
18
|
+
const compiling = app.world.tryResource(Gpu)?.pipelines.pending ?? 0;
|
|
19
|
+
throw new ShardError(
|
|
20
|
+
"verify/idle-timeout",
|
|
21
|
+
`The app never went idle for the capture (holding frames: ${held}; pipelines compiling: ${compiling})`,
|
|
22
|
+
{ hint: "Holding nothing: it was slow, not stuck; raise the plan's timeoutMs." }
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (loading(app) || !settled(app)) {
|
|
26
|
+
await sleep(16);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
await app.whenPresented();
|
|
30
|
+
if (!loading(app)) return;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function base64(bytes) {
|
|
34
|
+
let text = "";
|
|
35
|
+
for (let i = 0; i < bytes.length; i += 32768) {
|
|
36
|
+
text += String.fromCharCode(...bytes.subarray(i, i + 32768));
|
|
37
|
+
}
|
|
38
|
+
return btoa(text);
|
|
39
|
+
}
|
|
40
|
+
function installCapturePage(apps, host = {}) {
|
|
41
|
+
const list = Array.isArray(apps) ? apps : [apps];
|
|
42
|
+
const page = {
|
|
43
|
+
async apply(state) {
|
|
44
|
+
await host.apply?.(state);
|
|
45
|
+
},
|
|
46
|
+
async idle(timeoutMs = 3e4) {
|
|
47
|
+
const deadline = performance.now() + timeoutMs;
|
|
48
|
+
for (const app of list) await idleApp(app, deadline);
|
|
49
|
+
},
|
|
50
|
+
async step(name, args, trace) {
|
|
51
|
+
const run = host.steps?.[name];
|
|
52
|
+
if (!run) {
|
|
53
|
+
throw new ShardError("verify/unknown-step", `The page has no step "${name}"`, {
|
|
54
|
+
hint: `Its steps: ${Object.keys(host.steps ?? {}).join(", ") || "none"}.`
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
const result = await run(args);
|
|
58
|
+
if (!trace) return { result: result ?? null };
|
|
59
|
+
const latencies = await Promise.all(list.map((app) => app.trace(`step:${name}`)));
|
|
60
|
+
return { result: result ?? null, latencyMs: Math.max(...latencies) };
|
|
61
|
+
},
|
|
62
|
+
async probe() {
|
|
63
|
+
return await host.probe?.() ?? null;
|
|
64
|
+
},
|
|
65
|
+
async conditions(conditions) {
|
|
66
|
+
const scale = conditions.renderScale;
|
|
67
|
+
if (!scale) return;
|
|
68
|
+
for (const app of list) {
|
|
69
|
+
if (!app.world.tryResource(RenderScale)) continue;
|
|
70
|
+
app.world.patchResource(RenderScale, { mode: scale.mode, scale: scale.scale });
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
async snapshot(selector) {
|
|
74
|
+
const canvas = document.querySelector(selector);
|
|
75
|
+
if (!(canvas instanceof HTMLCanvasElement)) {
|
|
76
|
+
throw new ShardError("verify/no-canvas", `No canvas matches "${selector}"`);
|
|
77
|
+
}
|
|
78
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
79
|
+
if (!blob) throw new ShardError("verify/no-canvas", `The canvas "${selector}" has no pixels`);
|
|
80
|
+
const png = base64(new Uint8Array(await blob.arrayBuffer()));
|
|
81
|
+
return { png, width: canvas.width, height: canvas.height };
|
|
82
|
+
},
|
|
83
|
+
reset() {
|
|
84
|
+
for (const app of list) app.world.tryResource(MetricsResource)?.reset();
|
|
85
|
+
},
|
|
86
|
+
record(meta) {
|
|
87
|
+
for (const app of list) {
|
|
88
|
+
const metrics = app.world.tryResource(MetricsResource);
|
|
89
|
+
if (metrics) return metrics.record(meta);
|
|
90
|
+
}
|
|
91
|
+
throw new ShardError("verify/no-metrics", "No app on this page has metrics", {
|
|
92
|
+
hint: "Add metricsPlugin() from @aethervtt/shard-verify/metrics to the app."
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
window.__shardCapture = page;
|
|
97
|
+
void Promise.all(list.map((app) => app.whenUsable())).then(() => {
|
|
98
|
+
window.__shardReady = true;
|
|
99
|
+
});
|
|
100
|
+
return page;
|
|
101
|
+
}
|
|
102
|
+
export {
|
|
103
|
+
installCapturePage
|
|
104
|
+
};
|
package/dist/plan.d.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { JsonSchema } from '@aethervtt/shard-core';
|
|
2
|
+
import type { Tolerance } from './compare.js';
|
|
3
|
+
export type BrowserName = 'chromium' | 'webkit' | 'firefox';
|
|
4
|
+
export type CaptureScope = 'canvas' | 'page';
|
|
5
|
+
/**
|
|
6
|
+
* A capture and measurement run (0062): which page, in which browsers, at which sizes, with what
|
|
7
|
+
* shots, host steps and checks, measured scenarios, and pass thresholds. Every renderer in a
|
|
8
|
+
* comparison runs the same plan.
|
|
9
|
+
*/
|
|
10
|
+
export interface CapturePlan {
|
|
11
|
+
url: string;
|
|
12
|
+
browsers: BrowserName[];
|
|
13
|
+
dpr: number[];
|
|
14
|
+
/** CSS pixels. */
|
|
15
|
+
viewport: [number, number];
|
|
16
|
+
/** canvas: the canvas's own pixels, alpha included. page: the viewport, DOM and all. */
|
|
17
|
+
scope: CaptureScope;
|
|
18
|
+
/** The canvas a canvas-scope shot reads. Default the page's first `canvas`. */
|
|
19
|
+
canvas: string;
|
|
20
|
+
/** What records name the fixture. Default the URL's path and hash. */
|
|
21
|
+
fixture: string;
|
|
22
|
+
/** What records name the renderer, when the page doesn't say. */
|
|
23
|
+
renderer?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The graphics API the page is asked for (0064), as `?backend=`. Unset, the page picks. A browser
|
|
26
|
+
* without it skips the plan: no WebGPU skips a 'webgpu' plan, not a 'webgl2' one.
|
|
27
|
+
*/
|
|
28
|
+
backend?: 'auto' | 'webgpu' | 'webgl2';
|
|
29
|
+
conditions: PlanConditions;
|
|
30
|
+
/** Default tolerance for every shot. */
|
|
31
|
+
tolerance: Tolerance;
|
|
32
|
+
/** Browser contexts against one host session. Default one, `main`. */
|
|
33
|
+
clients: PlanClient[];
|
|
34
|
+
/** Shots every client takes before the steps run. */
|
|
35
|
+
shots: PlanShot[];
|
|
36
|
+
steps: PlanStep[];
|
|
37
|
+
scenarios: PlanScenario[];
|
|
38
|
+
thresholds: Thresholds;
|
|
39
|
+
/** Longest wait for readiness, an idle frame, or a step, in ms. Default 60 s. */
|
|
40
|
+
timeoutMs: number;
|
|
41
|
+
}
|
|
42
|
+
export interface PlanConditions {
|
|
43
|
+
/** Pins Shard's RenderScale (0051) for the run, so resolution can't buy frame time. */
|
|
44
|
+
renderScale?: {
|
|
45
|
+
mode: 'fixed';
|
|
46
|
+
scale: number;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export interface PlanClient {
|
|
50
|
+
name: string;
|
|
51
|
+
/** Passed to the page as `?role=`. */
|
|
52
|
+
role?: string;
|
|
53
|
+
/** More query parameters for this client's page. */
|
|
54
|
+
query?: Record<string, string>;
|
|
55
|
+
/** Its own shots, after the plan's. */
|
|
56
|
+
shots?: PlanShot[];
|
|
57
|
+
}
|
|
58
|
+
export interface PlanShot {
|
|
59
|
+
name: string;
|
|
60
|
+
/** Given to the page's `__shardCapture.apply(state)`: camera, view, toggles. */
|
|
61
|
+
state?: Record<string, unknown>;
|
|
62
|
+
scope?: CaptureScope;
|
|
63
|
+
canvas?: string;
|
|
64
|
+
tolerance?: Tolerance;
|
|
65
|
+
}
|
|
66
|
+
/** One host action, run in each of its clients in order, then checked in every client. */
|
|
67
|
+
export interface PlanStep {
|
|
68
|
+
name: string;
|
|
69
|
+
/** The page's step to run (`__shardCapture.step`). Default `name`. */
|
|
70
|
+
run?: string;
|
|
71
|
+
args?: unknown;
|
|
72
|
+
/** Clients that run it. Default all; every client is still checked. */
|
|
73
|
+
clients?: string[];
|
|
74
|
+
/** Stamp the step with `app.trace` and report `step.latencyMs` to the checks. */
|
|
75
|
+
trace?: boolean;
|
|
76
|
+
/** Probe path → matcher, per client name. */
|
|
77
|
+
expect?: Record<string, Expectations>;
|
|
78
|
+
/** Take a shot of every client after the step (named after it), or the named shots. */
|
|
79
|
+
capture?: boolean | PlanShot[];
|
|
80
|
+
}
|
|
81
|
+
/** Dotted path into what the page's `probe()` returned (plus `step.latencyMs`) → matcher. */
|
|
82
|
+
export type Expectations = Record<string, Matcher>;
|
|
83
|
+
export interface Matcher {
|
|
84
|
+
equals?: unknown;
|
|
85
|
+
includes?: unknown[];
|
|
86
|
+
excludes?: unknown[];
|
|
87
|
+
min?: number;
|
|
88
|
+
max?: number;
|
|
89
|
+
/** Deep-equal to the value at another path (a fresh load's entity set, say). */
|
|
90
|
+
sameAs?: string;
|
|
91
|
+
}
|
|
92
|
+
/** A measured run: steps start it, the window runs `seconds`, then each client records. */
|
|
93
|
+
export interface PlanScenario {
|
|
94
|
+
name: string;
|
|
95
|
+
steps?: PlanStep[];
|
|
96
|
+
seconds: number;
|
|
97
|
+
/** Clients that record. Default all. */
|
|
98
|
+
clients?: string[];
|
|
99
|
+
}
|
|
100
|
+
export interface ThresholdRule {
|
|
101
|
+
max?: number;
|
|
102
|
+
min?: number;
|
|
103
|
+
/** A renderer in the same run whose value, times `ratio`, is the budget. */
|
|
104
|
+
maxRatioTo?: string;
|
|
105
|
+
ratio?: number;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* A budget on one span of a record's `breakdown` (0074): `{ "span": "partition/spawn", "p95": 2 }`.
|
|
109
|
+
* Its p50, p95 or max (ms) must stay at or under the number given.
|
|
110
|
+
*/
|
|
111
|
+
export interface SpanThreshold {
|
|
112
|
+
span: string;
|
|
113
|
+
p50?: number;
|
|
114
|
+
p95?: number;
|
|
115
|
+
max?: number;
|
|
116
|
+
}
|
|
117
|
+
/** A scenario's rules: metric path (`frameTime.p95`) → rule, and span budgets under `spans`. */
|
|
118
|
+
export interface ScenarioThresholds {
|
|
119
|
+
[metric: string]: ThresholdRule | readonly SpanThreshold[] | undefined;
|
|
120
|
+
spans?: readonly SpanThreshold[];
|
|
121
|
+
}
|
|
122
|
+
/** Scenario → its rules. */
|
|
123
|
+
export type Thresholds = Record<string, ScenarioThresholds>;
|
|
124
|
+
/** JSON Schema for capture plans (`.shard/schemas/capture-plan.schema.json`). */
|
|
125
|
+
export declare function capturePlanJsonSchema(): JsonSchema;
|
|
126
|
+
/**
|
|
127
|
+
* Reads a plan, filling in defaults, or throws `verify/invalid-plan` listing every problem: schema
|
|
128
|
+
* errors, then references to clients that don't exist and names used twice.
|
|
129
|
+
*/
|
|
130
|
+
export declare function parsePlan(json: unknown, file?: string): CapturePlan;
|
|
131
|
+
/** A shot's id in a capture run: `chromium/main/map-close@2x` (the capture's path, less `.png`). */
|
|
132
|
+
export declare function shotId(browser: string, client: string, shot: string, dpr: number): string;
|