@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.
@@ -0,0 +1,246 @@
1
+ import { AssetServerResource } from "@aethervtt/shard-assets";
2
+ import {
3
+ defineResource,
4
+ defineSchema,
5
+ defineSystem,
6
+ First,
7
+ ShardError,
8
+ t
9
+ } from "@aethervtt/shard-core";
10
+ import { Gpu, Graph, RenderScale, Window } from "@aethervtt/shard-render";
11
+ import {
12
+ AppControlResource,
13
+ definePlugin,
14
+ FrameDemand,
15
+ perfBreakdown
16
+ } from "@aethervtt/shard-runtime";
17
+ import { distribution, percentile, round, SampleRing } from "../stats.js";
18
+ const FRAME_SAMPLES = 1 << 15;
19
+ const EVENT_SAMPLES = 1 << 12;
20
+ class Metrics {
21
+ windowMs;
22
+ renderer;
23
+ app;
24
+ now;
25
+ navigationStart;
26
+ performance;
27
+ frames = new SampleRing(FRAME_SAMPLES);
28
+ gpuFrames = new SampleRing(FRAME_SAMPLES);
29
+ traces = new SampleRing(EVENT_SAMPLES);
30
+ longTasks = new SampleRing(EVENT_SAMPLES);
31
+ windowStart = Number.NEGATIVE_INFINITY;
32
+ lastStart = Number.NaN;
33
+ /** The last frame asked for the next one, so the interval between them is a frame time. */
34
+ continued = false;
35
+ gpuSamples = 0;
36
+ scaleMin = Number.POSITIVE_INFINITY;
37
+ scaleMax = Number.NEGATIVE_INFINITY;
38
+ scaleAuto = false;
39
+ scaled = false;
40
+ pipelinesMs = Number.NaN;
41
+ assetsMs = Number.NaN;
42
+ constructor(app, options = {}) {
43
+ this.app = app;
44
+ this.windowMs = options.windowMs ?? 3e4;
45
+ this.renderer = options.renderer ?? "shard";
46
+ this.now = options.now ?? (() => performance.now());
47
+ this.navigationStart = options.navigationStart ?? 0;
48
+ this.performance = options.performance;
49
+ }
50
+ /** Starts a new window: what was measured so far no longer counts (startup numbers stay). */
51
+ reset() {
52
+ this.windowStart = this.now();
53
+ this.frames.clear();
54
+ this.gpuFrames.clear();
55
+ this.traces.clear();
56
+ this.longTasks.clear();
57
+ this.lastStart = Number.NaN;
58
+ this.scaleMin = Number.POSITIVE_INFINITY;
59
+ this.scaleMax = Number.NEGATIVE_INFINITY;
60
+ this.scaleAuto = false;
61
+ this.scaled = false;
62
+ }
63
+ /** @internal As a frame starts: the interval since the last one, if that one asked for this. */
64
+ frameStart() {
65
+ const now = this.now();
66
+ if (this.continued && !Number.isNaN(this.lastStart)) this.frames.push(now, now - this.lastStart);
67
+ this.lastStart = now;
68
+ }
69
+ /** @internal As a frame ends: GPU time, render scale, and whether another frame follows. */
70
+ frameEnd() {
71
+ const world = this.app.world;
72
+ const now = this.now();
73
+ const demand = world.resource(FrameDemand);
74
+ this.continued = demand.mode !== "on-demand" || demand.active || world.resource(AppControlResource).pendingSteps > 0;
75
+ const timer = world.tryResource(Graph)?.timer;
76
+ if (timer && timer.frameSamples !== this.gpuSamples) {
77
+ this.gpuSamples = timer.frameSamples;
78
+ this.gpuFrames.push(now, timer.frameMs);
79
+ }
80
+ const scale = world.tryResource(RenderScale);
81
+ if (scale) {
82
+ this.scaled = true;
83
+ if (scale.scale < this.scaleMin) this.scaleMin = scale.scale;
84
+ if (scale.scale > this.scaleMax) this.scaleMax = scale.scale;
85
+ if (scale.mode === "auto") this.scaleAuto = true;
86
+ }
87
+ }
88
+ /** @internal */
89
+ trace(ms) {
90
+ this.traces.push(this.now(), ms);
91
+ }
92
+ /** @internal */
93
+ longTask(start, duration) {
94
+ this.longTasks.push(start, duration);
95
+ }
96
+ /** @internal The first usable frame was presented: startup work in flight until now counts. */
97
+ usable(at) {
98
+ const world = this.app.world;
99
+ this.pipelinesMs = world.tryResource(Gpu)?.pipelines.busyMs(at) ?? 0;
100
+ this.assetsMs = world.tryResource(AssetServerResource)?.busyMs(at) ?? 0;
101
+ }
102
+ /**
103
+ * The record of this app so far: startup, and the current window (since `reset()`, at most
104
+ * `windowMs`). Throws `verify/not-usable` before the first usable frame.
105
+ */
106
+ record(meta) {
107
+ const startup = this.app.startup;
108
+ if (Number.isNaN(startup.usable) || Number.isNaN(this.pipelinesMs)) {
109
+ throw new ShardError("verify/not-usable", "The app has no usable frame to measure from yet", {
110
+ hint: "The host calls app.markUsable() once the scene is interactive; records start after the frame that follows."
111
+ });
112
+ }
113
+ const now = this.now();
114
+ const since = Math.max(this.windowStart, now - this.windowMs);
115
+ const nav = this.navigationStart;
116
+ const frames = this.frames.since(since);
117
+ const gpu = this.gpuFrames.since(since);
118
+ const traces = this.traces.since(this.windowStart);
119
+ const tasks = this.longTasks.since(since);
120
+ let totalMs = 0;
121
+ let maxMs = 0;
122
+ for (const d of tasks) {
123
+ totalMs += d;
124
+ if (d > maxMs) maxMs = d;
125
+ }
126
+ const frameTime = {
127
+ ...roundAll(distribution(frames)),
128
+ n: frames.length
129
+ };
130
+ if (gpu.length > 0) frameTime.gpuP95 = round(percentile(gpu, 0.95));
131
+ const device = this.app.world.tryResource(Gpu);
132
+ const memory = device?.memory() ?? { bytes: 0, byCategory: {} };
133
+ return {
134
+ version: 2,
135
+ renderer: meta.renderer ?? this.renderer,
136
+ fixture: meta.fixture,
137
+ scenario: meta.scenario,
138
+ device: this.device(),
139
+ renderScale: this.scaled ? { mode: this.scaleAuto ? "auto" : "fixed", min: this.scaleMin, max: this.scaleMax } : { mode: "none", min: 1, max: 1 },
140
+ coldStart: {
141
+ total: round(startup.initEnd - nav),
142
+ modules: round(startup.initStart - nav),
143
+ device: round(device?.deviceMs ?? 0),
144
+ pipelines: round(this.pipelinesMs),
145
+ assets: round(this.assetsMs)
146
+ },
147
+ firstUsableFrame: round(startup.usable - nav),
148
+ patchToFrame: {
149
+ p50: round(percentile(traces, 0.5)),
150
+ p95: round(percentile(traces, 0.95)),
151
+ n: traces.length
152
+ },
153
+ frameTime,
154
+ longTasks: { count: tasks.length, totalMs: round(totalMs), maxMs: round(maxMs) },
155
+ gpuMemory: { bytes: memory.bytes, byCategory: memory.byCategory },
156
+ download: this.performance?.downloads?.() ?? { transferred: 0, decoded: 0 },
157
+ breakdown: perfBreakdown(this.app.world)
158
+ };
159
+ }
160
+ device() {
161
+ const gpu = this.app.world.tryResource(Gpu);
162
+ const info = gpu?.adapter.info;
163
+ const name = info ? [info.vendor, info.architecture, info.description].filter(Boolean).join(" ") : "";
164
+ const page = globalThis;
165
+ const target = this.app.world.tryResource(Window);
166
+ const dpr = page.devicePixelRatio ?? target?.pixelRatio ?? 1;
167
+ const viewport = page.innerWidth !== void 0 ? [page.innerWidth, page.innerHeight ?? 0] : [Math.round((target?.width ?? 0) / dpr), Math.round((target?.height ?? 0) / dpr)];
168
+ return { ua: this.performance?.userAgent ?? "unknown", gpu: name || "unknown", dpr, viewport };
169
+ }
170
+ }
171
+ function roundAll(d) {
172
+ return { p50: round(d.p50), p95: round(d.p95), p99: round(d.p99) };
173
+ }
174
+ const MetricsResource = defineResource("verify/Metrics", {
175
+ description: "What the app measured for performance records (0062): metricsPlugin's."
176
+ });
177
+ const frameStart = defineSystem({
178
+ name: "verify/frame-start",
179
+ description: "Times the interval since the last frame, when that frame asked for this one.",
180
+ setup: (world) => ({ metrics: world.resource(MetricsResource) }),
181
+ run: ({ metrics }) => metrics.frameStart()
182
+ });
183
+ const metricsMethods = [
184
+ {
185
+ name: "metrics.record",
186
+ description: "A performance record (spec 0062) of the running app: cold start, first usable frame, patch-to-frame latency, frame times, long tasks, GPU memory, downloads, and the breakdown (0074: the 10 CPU and GPU spans with the highest p95), over the window since metrics.reset (at most 30 s).",
187
+ params: defineSchema("verify/MetricsRecordParams", {
188
+ fixture: t.string({ required: true, description: 'What was loaded, e.g. "tabletop-small".' }),
189
+ scenario: t.string({ required: true, description: 'What ran, e.g. "tabletop-pan".' }),
190
+ renderer: t.string({ description: 'Overrides the renderer name, e.g. "shard@1a2b3c4".' })
191
+ }),
192
+ handler: ({ world }, p) => metricsOf(world).record({
193
+ fixture: p.fixture,
194
+ scenario: p.scenario,
195
+ renderer: p.renderer || void 0
196
+ })
197
+ },
198
+ {
199
+ name: "metrics.reset",
200
+ description: "Starts a new measurement window: frame times, traces and long tasks so far no longer count.",
201
+ params: defineSchema("verify/MetricsResetParams", {}),
202
+ handler: ({ world }) => {
203
+ metricsOf(world).reset();
204
+ return null;
205
+ }
206
+ }
207
+ ];
208
+ function metricsOf(world) {
209
+ const metrics = world.tryResource(MetricsResource);
210
+ if (!metrics) {
211
+ throw new ShardError("verify/no-metrics", "This app has no metrics", {
212
+ hint: "Add metricsPlugin() from @aethervtt/shard-verify/metrics."
213
+ });
214
+ }
215
+ return metrics;
216
+ }
217
+ function metricsPlugin(options = {}) {
218
+ const off = [];
219
+ return definePlugin({
220
+ name: "verify/metrics",
221
+ provides: [MetricsResource],
222
+ dependencies: ["core/time"],
223
+ build(app) {
224
+ const metrics = new Metrics(app, options);
225
+ app.insertResource(MetricsResource, metrics);
226
+ app.addSystems(First, frameStart);
227
+ app.addMethod(...metricsMethods);
228
+ off.push(
229
+ app.onFrame(() => metrics.frameEnd()),
230
+ app.onTrace((_, ms) => metrics.trace(ms))
231
+ );
232
+ const longTasks = options.performance?.onLongTask?.((start, d) => metrics.longTask(start, d));
233
+ if (longTasks) off.push(longTasks);
234
+ void app.whenUsable().then((at) => metrics.usable(at));
235
+ },
236
+ dispose() {
237
+ for (const fn of off.splice(0)) fn();
238
+ }
239
+ });
240
+ }
241
+ export {
242
+ Metrics,
243
+ MetricsResource,
244
+ metricsMethods,
245
+ metricsPlugin
246
+ };
@@ -0,0 +1,48 @@
1
+ import type { LaunchOptions } from 'playwright';
2
+ import { type CheckFailure } from '../expect.js';
3
+ import { type BrowserName, type CapturePlan, type PlanClient } from '../plan.js';
4
+ import type { PerfRecord } from '../record.js';
5
+ import type { CaptureManifest } from '../run.js';
6
+ export interface CaptureOptions {
7
+ /** Where PNGs, records and `manifest.json` go. */
8
+ out: string;
9
+ /** Show the browser windows (also `SHARD_BROWSER_HEADED=1`). Default false. */
10
+ headed?: boolean;
11
+ /** Extra browser flags (also `SHARD_BROWSER_ARGS`, space-separated). */
12
+ browserArgs?: string[];
13
+ /**
14
+ * Chromium's release channel (also `SHARD_BROWSER_CHANNEL`): `chrome` runs the installed Chrome,
15
+ * headed at the display's refresh rate for frame-interval records. Default Playwright's Chromium.
16
+ */
17
+ channel?: string;
18
+ /** Progress lines. */
19
+ log?: (message: string) => void;
20
+ }
21
+ export interface CaptureRun {
22
+ manifest: CaptureManifest;
23
+ /** Every failed check, naming the step and the client. */
24
+ failures: CheckFailure[];
25
+ records: PerfRecord[];
26
+ pass: boolean;
27
+ }
28
+ /**
29
+ * How `shard capture` launches a browser at a DPR, for anything that has to start it the same way
30
+ * (tests probing for an adapter). Chromium runs as the full browser in its new headless mode: the
31
+ * headless shell has no GPU process. It also gets a real device scale: its DPR emulation alone
32
+ * reports `devicePixelRatio` 2 with a device-pixel box of CSS size, so a canvas would render at 1×
33
+ * and be scaled up. On Linux, WebGPU goes through Vulkan (Mesa's software driver on CI runners, as
34
+ * three.js runs its tests), drawing without a Vulkan surface. `SHARD_BROWSER_HEADED=1` shows the
35
+ * window (CI runs it under `xvfb-run`), and `SHARD_BROWSER_ARGS` adds flags.
36
+ */
37
+ export declare function browserLaunch(browser: BrowserName, dpr: number, options?: Pick<CaptureOptions, 'headed' | 'browserArgs' | 'channel'>): LaunchOptions;
38
+ /** Runs a capture plan in real browsers (0062). Checks that fail don't stop the run. */
39
+ export declare function runCapture(plan: CapturePlan, options: CaptureOptions): Promise<CaptureRun>;
40
+ /** The plan's URL with the backend it asks for, and the client's role and query parameters. */
41
+ export declare function clientUrl(url: string, client: PlanClient, backend?: string): string;
42
+ /**
43
+ * Headers for pages Shard serves to measure (0074): cross-origin isolation, so `performance.now()`
44
+ * steps 5 µs instead of 100 µs (`credentialless` COEP keeps cross-origin loads working without
45
+ * CORP headers), and `Document-Policy: js-profiling` for the JS Self-Profiling API. `shard dev`,
46
+ * the playground and the capture server send them.
47
+ */
48
+ export declare const ISOLATION_HEADERS: Readonly<Record<string, string>>;
@@ -0,0 +1,270 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { ShardError } from "@aethervtt/shard-core";
4
+ import { checkExpectations } from "../expect.js";
5
+ import {
6
+ shotId
7
+ } from "../plan.js";
8
+ import { sha256 } from "./png.js";
9
+ function browserLaunch(browser, dpr, options = {}) {
10
+ const extra = [
11
+ ...options.browserArgs ?? [],
12
+ ...process.env.SHARD_BROWSER_ARGS?.split(" ").filter(Boolean) ?? []
13
+ ];
14
+ const headless = !(options.headed ?? process.env.SHARD_BROWSER_HEADED === "1");
15
+ if (browser !== "chromium") return { headless, args: extra };
16
+ const args = [
17
+ "--enable-unsafe-webgpu",
18
+ "--enable-gpu-rasterization",
19
+ "--ignore-gpu-blocklist",
20
+ `--force-device-scale-factor=${dpr}`
21
+ ];
22
+ if (process.platform === "linux")
23
+ args.push("--enable-features=Vulkan", "--disable-vulkan-surface");
24
+ const channel = options.channel ?? process.env.SHARD_BROWSER_CHANNEL ?? "chromium";
25
+ return { headless, channel, args: [...args, ...extra] };
26
+ }
27
+ async function runCapture(plan, options) {
28
+ const playwright = await import("playwright");
29
+ const log = options.log ?? (() => {
30
+ });
31
+ const manifest = {
32
+ version: 1,
33
+ url: plan.url,
34
+ fixture: plan.fixture,
35
+ date: (/* @__PURE__ */ new Date()).toISOString(),
36
+ shots: [],
37
+ records: [],
38
+ steps: [],
39
+ skipped: []
40
+ };
41
+ const failures = [];
42
+ const records = [];
43
+ await mkdir(options.out, { recursive: true });
44
+ browsers: for (const name of plan.browsers) {
45
+ for (const dpr of plan.dpr) {
46
+ let browser;
47
+ try {
48
+ browser = await playwright[name].launch(browserLaunch(name, dpr, options));
49
+ } catch (err) {
50
+ const reason = err.message.split("\n")[0];
51
+ manifest.skipped.push({ browser: name, reason });
52
+ log(`${name}: skipped (${reason})`);
53
+ continue browsers;
54
+ }
55
+ try {
56
+ const run = await runSession(plan, name, dpr, browser, options.out, log);
57
+ if (typeof run === "string") {
58
+ const reason = UNAVAILABLE[run];
59
+ manifest.skipped.push({ browser: name, reason });
60
+ log(`${name}: skipped (${reason})`);
61
+ continue browsers;
62
+ }
63
+ manifest.shots.push(...run.shots);
64
+ manifest.steps.push(...run.steps);
65
+ manifest.records.push(...run.recordFiles);
66
+ records.push(...run.records);
67
+ for (const step of run.steps) failures.push(...step.failures);
68
+ } finally {
69
+ await browser.close();
70
+ }
71
+ }
72
+ }
73
+ if (manifest.shots.length === 0 && manifest.records.length === 0 && manifest.skipped.length > 0) {
74
+ throw new ShardError("verify/no-browser", "No browser in the plan could run it", {
75
+ hint: `${manifest.skipped.map((s) => `${s.browser}: ${s.reason}`).join("; ")}. Install one with \`pnpm exec playwright install chromium\`.`
76
+ });
77
+ }
78
+ await writeFile(join(options.out, "manifest.json"), `${JSON.stringify(manifest, null, 2)}
79
+ `);
80
+ return { manifest, failures, records, pass: failures.length === 0 };
81
+ }
82
+ const UNAVAILABLE = {
83
+ "no-webgpu": "no WebGPU in this build",
84
+ "no-adapter": "no WebGPU adapter",
85
+ "no-webgl2": "no WebGL2 with float render targets",
86
+ "no-graphics": "neither WebGPU nor WebGL2"
87
+ };
88
+ async function runSession(plan, browser, dpr, instance, out, log) {
89
+ const result = { shots: [], steps: [], records: [], recordFiles: [] };
90
+ const clients = [];
91
+ try {
92
+ for (const client of plan.clients) {
93
+ const context = await instance.newContext({
94
+ viewport: { width: plan.viewport[0], height: plan.viewport[1] },
95
+ deviceScaleFactor: dpr
96
+ });
97
+ const page = await context.newPage();
98
+ page.setDefaultTimeout(plan.timeoutMs);
99
+ clients.push({ plan: client, context, page });
100
+ const errors = [];
101
+ page.on("pageerror", (err) => errors.push(err.message));
102
+ await page.goto(clientUrl(plan.url, client, plan.backend));
103
+ const gpu = await page.evaluate(async (backend) => {
104
+ const webgpu = !navigator.gpu ? "no-webgpu" : await navigator.gpu.requestAdapter() ? "ok" : "no-adapter";
105
+ const gl = document.createElement("canvas").getContext("webgl2");
106
+ const webgl2 = gl?.getExtension("EXT_color_buffer_float") ? "ok" : "no-webgl2";
107
+ gl?.getExtension("WEBGL_lose_context")?.loseContext();
108
+ if (backend === "webgl2") return webgl2;
109
+ if (backend === "auto") return webgpu === "ok" || webgl2 === "ok" ? "ok" : "no-graphics";
110
+ return webgpu;
111
+ }, plan.backend);
112
+ if (gpu !== "ok") return gpu;
113
+ await page.waitForFunction(() => window.__shardReady === true, void 0, { timeout: plan.timeoutMs }).catch((err) => {
114
+ throw new ShardError(
115
+ "verify/not-ready",
116
+ `${clientUrl(plan.url, client)} never became ready`,
117
+ {
118
+ hint: `The page sets window.__shardReady through installCapturePage once the host calls app.markUsable().${errors.length ? ` Page errors: ${errors.join("; ")}` : ""}`,
119
+ cause: err
120
+ }
121
+ );
122
+ });
123
+ await page.evaluate((c) => window.__shardCapture.conditions(c), plan.conditions);
124
+ log(`${browser}@${dpr}x ${client.name}: ready`);
125
+ }
126
+ for (const client of clients) {
127
+ for (const shot of [...plan.shots, ...client.plan.shots ?? []]) {
128
+ result.shots.push(await takeShot(plan, browser, dpr, client, shot, out));
129
+ }
130
+ }
131
+ const failures = await runSteps(plan, plan.steps, browser, dpr, clients, out, result);
132
+ if (failures.length > 0) log(`${browser}@${dpr}x: ${failures.length} failed checks`);
133
+ for (const scenario of plan.scenarios) {
134
+ const recording = clients.filter(
135
+ (c) => !scenario.clients || scenario.clients.includes(c.plan.name)
136
+ );
137
+ for (const client of recording)
138
+ await client.page.evaluate(() => window.__shardCapture.reset());
139
+ await runSteps(plan, scenario.steps ?? [], browser, dpr, clients, out, result, scenario.name);
140
+ await new Promise((resolve) => setTimeout(resolve, scenario.seconds * 1e3));
141
+ for (const client of recording) {
142
+ const record = await client.page.evaluate((meta) => window.__shardCapture.record(meta), {
143
+ fixture: plan.fixture,
144
+ scenario: scenario.name,
145
+ renderer: plan.renderer
146
+ });
147
+ const file = `records/${scenario.name}/${browser}@${dpr}x-${client.plan.name}.json`;
148
+ await writeOut(join(out, file), `${JSON.stringify(record, null, 2)}
149
+ `);
150
+ result.records.push(record);
151
+ result.recordFiles.push(file);
152
+ }
153
+ log(`${browser}@${dpr}x: recorded ${scenario.name}`);
154
+ }
155
+ return result;
156
+ } finally {
157
+ for (const client of clients) await client.context.close();
158
+ }
159
+ }
160
+ async function runSteps(plan, steps, browser, dpr, clients, out, result, prefix) {
161
+ const all = [];
162
+ for (const step of steps) {
163
+ const name = prefix ? `${prefix}/${step.name}` : step.name;
164
+ const failures = [];
165
+ const latency = /* @__PURE__ */ new Map();
166
+ for (const client of clients) {
167
+ if (step.clients && !step.clients.includes(client.plan.name)) continue;
168
+ try {
169
+ const ran = await client.page.evaluate(
170
+ ({ run, args, trace }) => window.__shardCapture.step(run, args, trace),
171
+ { run: step.run ?? step.name, args: step.args ?? null, trace: step.trace ?? false }
172
+ );
173
+ latency.set(client.plan.name, ran.latencyMs);
174
+ if (ran.result !== null && ran.result !== void 0) {
175
+ const file = join(
176
+ out,
177
+ "results",
178
+ `${browser}@${dpr}x-${client.plan.name}`,
179
+ `${name}.json`
180
+ );
181
+ await writeOut(file, `${JSON.stringify(ran.result, null, 2)}
182
+ `);
183
+ }
184
+ } catch (err) {
185
+ failures.push({ step: name, client: client.plan.name, path: "", message: pageMessage(err) });
186
+ }
187
+ }
188
+ for (const client of clients) {
189
+ await client.page.evaluate((ms) => window.__shardCapture.idle(ms), plan.timeoutMs);
190
+ const expectations = step.expect?.[client.plan.name];
191
+ if (expectations) {
192
+ const probe = await client.page.evaluate(() => window.__shardCapture.probe());
193
+ const seen = { ...probe, step: { latencyMs: latency.get(client.plan.name) } };
194
+ failures.push(...checkExpectations(name, client.plan.name, seen, expectations));
195
+ }
196
+ if (step.capture) {
197
+ const shots = step.capture === true ? [{ name: step.name }] : step.capture;
198
+ for (const shot of shots) {
199
+ const named = { ...shot, name: prefix ? `${prefix}-${shot.name}` : shot.name };
200
+ result.shots.push(await takeShot(plan, browser, dpr, client, named, out));
201
+ }
202
+ }
203
+ }
204
+ result.steps.push({ name, browser, dpr, failures });
205
+ all.push(...failures);
206
+ }
207
+ return all;
208
+ }
209
+ async function takeShot(plan, browser, dpr, client, shot, out) {
210
+ const page = client.page;
211
+ if (shot.state) await page.evaluate((s) => window.__shardCapture.apply(s), shot.state);
212
+ await page.evaluate((ms) => window.__shardCapture.idle(ms), plan.timeoutMs);
213
+ const scope = shot.scope ?? plan.scope;
214
+ let png;
215
+ let width;
216
+ let height;
217
+ if (scope === "page") {
218
+ png = await page.screenshot({ type: "png", animations: "disabled", caret: "hide" });
219
+ width = Math.round(plan.viewport[0] * dpr);
220
+ height = Math.round(plan.viewport[1] * dpr);
221
+ } else {
222
+ const snap = await page.evaluate(
223
+ (s) => window.__shardCapture.snapshot(s),
224
+ shot.canvas ?? plan.canvas
225
+ );
226
+ png = Buffer.from(snap.png, "base64");
227
+ width = snap.width;
228
+ height = snap.height;
229
+ }
230
+ const id = shotId(browser, client.plan.name, shot.name, dpr);
231
+ await writeOut(join(out, `${id}.png`), png);
232
+ return {
233
+ id,
234
+ browser,
235
+ client: client.plan.name,
236
+ shot: shot.name,
237
+ dpr,
238
+ scope,
239
+ width,
240
+ height,
241
+ hash: sha256(png),
242
+ tolerance: { ...plan.tolerance, ...shot.tolerance }
243
+ };
244
+ }
245
+ function clientUrl(url, client, backend) {
246
+ const u = new URL(url);
247
+ if (backend) u.searchParams.set("backend", backend);
248
+ if (client.role) u.searchParams.set("role", client.role);
249
+ for (const [key, value] of Object.entries(client.query ?? {})) u.searchParams.set(key, value);
250
+ return u.toString();
251
+ }
252
+ async function writeOut(file, data) {
253
+ await mkdir(dirname(file), { recursive: true });
254
+ await writeFile(file, data);
255
+ }
256
+ function pageMessage(err) {
257
+ const message = err.message ?? String(err);
258
+ return message.replace(/^page\.evaluate: /, "").split("\n")[0];
259
+ }
260
+ const ISOLATION_HEADERS = {
261
+ "Cross-Origin-Opener-Policy": "same-origin",
262
+ "Cross-Origin-Embedder-Policy": "credentialless",
263
+ "Document-Policy": "js-profiling"
264
+ };
265
+ export {
266
+ ISOLATION_HEADERS,
267
+ browserLaunch,
268
+ clientUrl,
269
+ runCapture
270
+ };
@@ -0,0 +1,40 @@
1
+ import { type CapturePlan } from '../plan.js';
2
+ import { type PerfRecord } from '../record.js';
3
+ import { type ReportShot } from '../report.js';
4
+ import { type Approval, type CaptureManifest } from '../run.js';
5
+ import { type PerfCheck } from '../thresholds.js';
6
+ export declare function readPlan(file: string): Promise<CapturePlan>;
7
+ export declare function readManifest(captures: string): Promise<CaptureManifest>;
8
+ export interface CompareResult extends ReportShot {
9
+ hash: string;
10
+ }
11
+ export interface CompareRun {
12
+ pass: boolean;
13
+ shots: CompareResult[];
14
+ report: string;
15
+ }
16
+ /**
17
+ * Diffs each capture of a run against its approved image (0062) and writes an HTML report with
18
+ * heatmaps. A shot with no approved image is `new`, and fails until approved.
19
+ */
20
+ export declare function compareRun(captures: string, approved: string, options?: {
21
+ report?: string;
22
+ }): Promise<CompareRun>;
23
+ /**
24
+ * Approves a capture (0062): copies it into `approved/` and appends `{ shot, hash, reason, by,
25
+ * date }` to `approved/approvals.json`. Refuses without a reason.
26
+ */
27
+ export declare function approveShot(options: {
28
+ captures: string;
29
+ approved: string;
30
+ shot: string;
31
+ reason: string | undefined;
32
+ by: string;
33
+ date?: Date;
34
+ }): Promise<Approval>;
35
+ /** Every `.json` record under the given files and directories. */
36
+ export declare function readRecords(paths: readonly string[]): Promise<PerfRecord[]>;
37
+ /** `shard perf-check`: a plan's thresholds over records (0062). */
38
+ export declare function perfCheck(planFile: string, paths: readonly string[]): Promise<PerfCheck & {
39
+ records: number;
40
+ }>;