@aethervtt/shard-verify 0.1.0 → 0.2.1

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/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { compareImages, DEFAULT_TOLERANCE, deltaE2000, diffHeatmap, type ImageDiff, type RgbaImage, type Tolerance, } from './compare.js';
2
2
  export { type CheckFailure, checkExpectations, deepEqual, valueAt } from './expect.js';
3
- export { type BrowserName, type CapturePlan, type CaptureScope, capturePlanJsonSchema, type Expectations, type Matcher, type PlanClient, type PlanConditions, type PlanScenario, type PlanShot, type PlanStep, parsePlan, shotId, type ThresholdRule, type Thresholds, } from './plan.js';
3
+ export { type BrowserName, type CapturePlan, type CaptureScope, capturePlanJsonSchema, type Expectations, type Matcher, type PlanClient, type PlanConditions, type PlanScenario, type PlanShot, type PlanStep, parsePlan, type ScenarioThresholds, type SpanThreshold, shotId, type ThresholdRule, type Thresholds, } from './plan.js';
4
4
  export { type PerfDevice, type PerfRecord, parsePerfRecord, perfRecordJsonSchema, rendererName, validateJson, } from './record.js';
5
5
  export { type ReportShot, renderReport } from './report.js';
6
6
  export { type Approval, type ApprovalsFile, addApproval, type CapturedShot, type CaptureManifest, } from './run.js';
7
- export { type Breach, checkThresholds, type PerfCheck } from './thresholds.js';
7
+ export { type Breach, checkThresholds, type PerfCheck, spansThatGrew } from './thresholds.js';
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ import { renderReport } from "./report.js";
20
20
  import {
21
21
  addApproval
22
22
  } from "./run.js";
23
- import { checkThresholds } from "./thresholds.js";
23
+ import { checkThresholds, spansThatGrew } from "./thresholds.js";
24
24
  export {
25
25
  DEFAULT_TOLERANCE,
26
26
  addApproval,
@@ -37,6 +37,7 @@ export {
37
37
  renderReport,
38
38
  rendererName,
39
39
  shotId,
40
+ spansThatGrew,
40
41
  validateJson,
41
42
  valueAt
42
43
  };
@@ -11,7 +11,8 @@ import { Gpu, Graph, RenderScale, Window } from "@aethervtt/shard-render";
11
11
  import {
12
12
  AppControlResource,
13
13
  definePlugin,
14
- FrameDemand
14
+ FrameDemand,
15
+ perfBreakdown
15
16
  } from "@aethervtt/shard-runtime";
16
17
  import { distribution, percentile, round, SampleRing } from "../stats.js";
17
18
  const FRAME_SAMPLES = 1 << 15;
@@ -130,7 +131,7 @@ class Metrics {
130
131
  const device = this.app.world.tryResource(Gpu);
131
132
  const memory = device?.memory() ?? { bytes: 0, byCategory: {} };
132
133
  return {
133
- version: 1,
134
+ version: 2,
134
135
  renderer: meta.renderer ?? this.renderer,
135
136
  fixture: meta.fixture,
136
137
  scenario: meta.scenario,
@@ -152,7 +153,8 @@ class Metrics {
152
153
  frameTime,
153
154
  longTasks: { count: tasks.length, totalMs: round(totalMs), maxMs: round(maxMs) },
154
155
  gpuMemory: { bytes: memory.bytes, byCategory: memory.byCategory },
155
- download: this.performance?.downloads?.() ?? { transferred: 0, decoded: 0 }
156
+ download: this.performance?.downloads?.() ?? { transferred: 0, decoded: 0 },
157
+ breakdown: perfBreakdown(this.app.world)
156
158
  };
157
159
  }
158
160
  device() {
@@ -181,7 +183,7 @@ const frameStart = defineSystem({
181
183
  const metricsMethods = [
182
184
  {
183
185
  name: "metrics.record",
184
- 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, and downloads, over the window since metrics.reset (at most 30 s).",
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).",
185
187
  params: defineSchema("verify/MetricsRecordParams", {
186
188
  fixture: t.string({ required: true, description: 'What was loaded, e.g. "tabletop-small".' }),
187
189
  scenario: t.string({ required: true, description: 'What ran, e.g. "tabletop-pan".' }),
@@ -10,6 +10,11 @@ export interface CaptureOptions {
10
10
  headed?: boolean;
11
11
  /** Extra browser flags (also `SHARD_BROWSER_ARGS`, space-separated). */
12
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;
13
18
  /** Progress lines. */
14
19
  log?: (message: string) => void;
15
20
  }
@@ -29,8 +34,15 @@ export interface CaptureRun {
29
34
  * three.js runs its tests), drawing without a Vulkan surface. `SHARD_BROWSER_HEADED=1` shows the
30
35
  * window (CI runs it under `xvfb-run`), and `SHARD_BROWSER_ARGS` adds flags.
31
36
  */
32
- export declare function browserLaunch(browser: BrowserName, dpr: number, options?: Pick<CaptureOptions, 'headed' | 'browserArgs'>): LaunchOptions;
37
+ export declare function browserLaunch(browser: BrowserName, dpr: number, options?: Pick<CaptureOptions, 'headed' | 'browserArgs' | 'channel'>): LaunchOptions;
33
38
  /** Runs a capture plan in real browsers (0062). Checks that fail don't stop the run. */
34
39
  export declare function runCapture(plan: CapturePlan, options: CaptureOptions): Promise<CaptureRun>;
35
40
  /** The plan's URL with the backend it asks for, and the client's role and query parameters. */
36
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>>;
@@ -21,7 +21,8 @@ function browserLaunch(browser, dpr, options = {}) {
21
21
  ];
22
22
  if (process.platform === "linux")
23
23
  args.push("--enable-features=Vulkan", "--disable-vulkan-surface");
24
- return { headless, channel: "chromium", args: [...args, ...extra] };
24
+ const channel = options.channel ?? process.env.SHARD_BROWSER_CHANNEL ?? "chromium";
25
+ return { headless, channel, args: [...args, ...extra] };
25
26
  }
26
27
  async function runCapture(plan, options) {
27
28
  const playwright = await import("playwright");
@@ -170,6 +171,16 @@ async function runSteps(plan, steps, browser, dpr, clients, out, result, prefix)
170
171
  { run: step.run ?? step.name, args: step.args ?? null, trace: step.trace ?? false }
171
172
  );
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
+ }
173
184
  } catch (err) {
174
185
  failures.push({ step: name, client: client.plan.name, path: "", message: pageMessage(err) });
175
186
  }
@@ -246,7 +257,13 @@ function pageMessage(err) {
246
257
  const message = err.message ?? String(err);
247
258
  return message.replace(/^page\.evaluate: /, "").split("\n")[0];
248
259
  }
260
+ const ISOLATION_HEADERS = {
261
+ "Cross-Origin-Opener-Policy": "same-origin",
262
+ "Cross-Origin-Embedder-Policy": "credentialless",
263
+ "Document-Policy": "js-profiling"
264
+ };
249
265
  export {
266
+ ISOLATION_HEADERS,
250
267
  browserLaunch,
251
268
  clientUrl,
252
269
  runCapture
@@ -1,3 +1,3 @@
1
- export { browserLaunch, type CaptureOptions, type CaptureRun, clientUrl, runCapture, } from './capture.js';
1
+ export { browserLaunch, type CaptureOptions, type CaptureRun, clientUrl, ISOLATION_HEADERS, runCapture, } from './capture.js';
2
2
  export { approveShot, type CompareResult, type CompareRun, compareRun, perfCheck, readManifest, readPlan, readRecords, } from './files.js';
3
3
  export { decodePng, encodePng, sha256 } from './png.js';
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  browserLaunch,
3
3
  clientUrl,
4
+ ISOLATION_HEADERS,
4
5
  runCapture
5
6
  } from "./capture.js";
6
7
  import {
@@ -13,6 +14,7 @@ import {
13
14
  } from "./files.js";
14
15
  import { decodePng, encodePng, sha256 } from "./png.js";
15
16
  export {
17
+ ISOLATION_HEADERS,
16
18
  approveShot,
17
19
  browserLaunch,
18
20
  clientUrl,
package/dist/plan.d.ts CHANGED
@@ -104,8 +104,23 @@ export interface ThresholdRule {
104
104
  maxRatioTo?: string;
105
105
  ratio?: number;
106
106
  }
107
- /** Scenario → metric path (`frameTime.p95`) → rule. */
108
- export type Thresholds = Record<string, Record<string, ThresholdRule>>;
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>;
109
124
  /** JSON Schema for capture plans (`.shard/schemas/capture-plan.schema.json`). */
110
125
  export declare function capturePlanJsonSchema(): JsonSchema;
111
126
  /**
package/dist/plan.js CHANGED
@@ -122,6 +122,23 @@ function capturePlanJsonSchema() {
122
122
  type: "object",
123
123
  additionalProperties: {
124
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
+ },
125
142
  additionalProperties: {
126
143
  type: "object",
127
144
  additionalProperties: false,
package/dist/record.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { JsonSchema } from '@aethervtt/shard-core';
1
+ import type { JsonSchema, SpanStats } from '@aethervtt/shard-core';
2
2
  import { ShardError } from '@aethervtt/shard-core';
3
3
  /**
4
4
  * One measured run of one scenario on one renderer (0062). Shard's `metrics.record` writes it, and
@@ -6,7 +6,8 @@ import { ShardError } from '@aethervtt/shard-core';
6
6
  * sizes bytes.
7
7
  */
8
8
  export interface PerfRecord {
9
- version: 1;
9
+ /** 2 adds `breakdown` (0074); version 1 records still read. */
10
+ version: 1 | 2;
10
11
  /** `shard@<sha>`, `three@0.160.1`: the part before `@` names the renderer in thresholds. */
11
12
  renderer: string;
12
13
  fixture: string;
@@ -61,6 +62,14 @@ export interface PerfRecord {
61
62
  transferred: number;
62
63
  decoded: number;
63
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
+ };
64
73
  }
65
74
  export interface PerfDevice {
66
75
  ua: string;
package/dist/record.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import { ShardError } from "@aethervtt/shard-core";
2
2
  const ms = { type: "number", minimum: 0 };
3
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
+ };
4
21
  function perfRecordJsonSchema() {
5
22
  return {
6
23
  $schema: "https://json-schema.org/draft/2020-12/schema",
@@ -26,7 +43,7 @@ function perfRecordJsonSchema() {
26
43
  ],
27
44
  properties: {
28
45
  $schema: { type: "string" },
29
- version: { const: 1 },
46
+ version: { enum: [1, 2] },
30
47
  renderer: {
31
48
  type: "string",
32
49
  minLength: 1,
@@ -98,6 +115,13 @@ function perfRecordJsonSchema() {
98
115
  additionalProperties: false,
99
116
  required: ["transferred", "decoded"],
100
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 }
101
125
  }
102
126
  }
103
127
  };
@@ -15,6 +15,17 @@ export interface Breach {
15
15
  value: number | undefined;
16
16
  };
17
17
  message: string;
18
+ /** A span budget's span (0074). */
19
+ span?: string;
20
+ /**
21
+ * Against a baseline: the spans whose p95 grew the most from its record to this one (0074), when
22
+ * both records have a breakdown.
23
+ */
24
+ grew?: {
25
+ span: string;
26
+ p95: number;
27
+ baselineP95: number;
28
+ }[];
18
29
  }
19
30
  export interface PerfCheck {
20
31
  pass: boolean;
@@ -29,3 +40,12 @@ export interface PerfCheck {
29
40
  * run has one; with no baseline record, the rule fails.
30
41
  */
31
42
  export declare function checkThresholds(records: readonly PerfRecord[], thresholds: Thresholds): PerfCheck;
43
+ /**
44
+ * The spans whose p95 grew the most from `baseline` to `record` (0074), largest growth first: a
45
+ * span missing from the baseline's breakdown counts from 0. Empty unless both have a breakdown.
46
+ */
47
+ export declare function spansThatGrew(record: PerfRecord, baseline: PerfRecord, limit?: number): {
48
+ span: string;
49
+ p95: number;
50
+ baselineP95: number;
51
+ }[];
@@ -4,18 +4,28 @@ function checkThresholds(records, thresholds) {
4
4
  const breaches = [];
5
5
  let checked = 0;
6
6
  for (const [scenario, rules] of Object.entries(thresholds)) {
7
+ const metrics = [];
8
+ for (const [metric, rule] of Object.entries(rules)) {
9
+ if (metric !== "spans" && rule && !Array.isArray(rule))
10
+ metrics.push([metric, rule]);
11
+ }
7
12
  const baselines = new Set(
8
- Object.values(rules).map((r) => r.maxRatioTo).filter((r) => r !== void 0)
13
+ metrics.map(([, r]) => r.maxRatioTo).filter((r) => r !== void 0)
9
14
  );
10
15
  const subjects = records.filter(
11
16
  (r) => r.scenario === scenario && !baselines.has(rendererName(r))
12
17
  );
13
18
  for (const record of subjects) {
14
- for (const [metric, rule] of Object.entries(rules)) {
19
+ for (const [metric, rule] of metrics) {
15
20
  checked++;
16
21
  const breach = apply(record, metric, rule, records);
17
22
  if (breach) breaches.push(breach);
18
23
  }
24
+ for (const rule of rules.spans ?? []) {
25
+ checked++;
26
+ const breach = applySpan(record, rule);
27
+ if (breach) breaches.push(breach);
28
+ }
19
29
  }
20
30
  }
21
31
  return { pass: breaches.length === 0, checked, breaches };
@@ -64,12 +74,62 @@ function apply(record, metric, rule, records) {
64
74
  const baseValue = metricOf(baseline, metric);
65
75
  const limit = baseValue === void 0 ? void 0 : baseValue * ratio;
66
76
  if (limit !== void 0 && value <= limit) return void 0;
67
- return {
77
+ const grew = spansThatGrew(record, baseline);
78
+ const breach = {
68
79
  ...base,
69
80
  budget: `max ${ratio} \xD7 ${baseline.renderer} (${baseValue ?? "none"})${limit === void 0 ? "" : ` = ${round(limit)}`}`,
70
81
  baseline: { renderer: baseline.renderer, value: baseValue },
71
- message: limit === void 0 ? `${where}: ${baseline.renderer}'s record has no ${metric}` : `${where}: ${value} is over ${ratio} \xD7 ${baseline.renderer}'s ${baseValue} (${round(limit)})`
82
+ message: limit === void 0 ? `${where}: ${baseline.renderer}'s record has no ${metric}` : `${where}: ${value} is over ${ratio} \xD7 ${baseline.renderer}'s ${baseValue} (${round(limit)})${grew.length > 0 ? `; grew most: ${grew.map((g) => `${g.span} ${g.baselineP95} \u2192 ${g.p95} ms`).join(", ")}` : ""}`
83
+ };
84
+ if (grew.length > 0) breach.grew = grew;
85
+ return breach;
86
+ }
87
+ function spansOf(record) {
88
+ return record.breakdown ? [...record.breakdown.cpu, ...record.breakdown.gpu] : [];
89
+ }
90
+ function spansThatGrew(record, baseline, limit = 3) {
91
+ if (!record.breakdown || !baseline.breakdown) return [];
92
+ const before = new Map(spansOf(baseline).map((s) => [s.span, s.p95]));
93
+ return spansOf(record).map((s) => ({ span: s.span, p95: s.p95, baselineP95: before.get(s.span) ?? 0 })).filter((g) => g.p95 > g.baselineP95).sort((a, b) => b.p95 - b.baselineP95 - (a.p95 - a.baselineP95) || (a.span < b.span ? -1 : 1)).slice(0, limit);
94
+ }
95
+ function applySpan(record, rule) {
96
+ const stats = spansOf(record).find((s) => s.span === rule.span);
97
+ const where = `${record.scenario} span ${rule.span} (${record.renderer})`;
98
+ const base = {
99
+ scenario: record.scenario,
100
+ renderer: record.renderer,
101
+ fixture: record.fixture,
102
+ span: rule.span
72
103
  };
104
+ if (!stats) {
105
+ const metric = `span:${rule.span}`;
106
+ return {
107
+ ...base,
108
+ metric,
109
+ value: void 0,
110
+ budget: describeSpan(rule),
111
+ message: record.breakdown ? `${where}: the record's breakdown has no ${rule.span}` : `${where}: the record has no breakdown (version 1)`
112
+ };
113
+ }
114
+ for (const key of ["p50", "p95", "max"]) {
115
+ const limit = rule[key];
116
+ if (limit === void 0 || stats[key] <= limit) continue;
117
+ return {
118
+ ...base,
119
+ metric: `span:${rule.span}.${key}`,
120
+ value: stats[key],
121
+ budget: `${key} ${limit}`,
122
+ message: `${where}: ${key} ${stats[key]} ms is over ${limit} ms`
123
+ };
124
+ }
125
+ return void 0;
126
+ }
127
+ function describeSpan(rule) {
128
+ const parts = [];
129
+ for (const key of ["p50", "p95", "max"]) {
130
+ if (rule[key] !== void 0) parts.push(`${key} ${rule[key]}`);
131
+ }
132
+ return parts.join(", ");
73
133
  }
74
134
  function findBaseline(record, renderer, records) {
75
135
  const candidates = records.filter(
@@ -91,5 +151,6 @@ function round(value) {
91
151
  return Math.round(value * 100) / 100;
92
152
  }
93
153
  export {
94
- checkThresholds
154
+ checkThresholds,
155
+ spansThatGrew
95
156
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-verify",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -28,13 +28,13 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@aethervtt/shard-assets": "0.1.0",
32
- "@aethervtt/shard-core": "0.1.0",
33
- "@aethervtt/shard-gpu": "0.1.0",
34
- "@aethervtt/shard-platform": "0.1.0",
35
- "@aethervtt/shard-render": "0.1.0",
36
- "@aethervtt/shard-runtime": "0.1.0",
37
- "@aethervtt/shard-texture": "0.1.0"
31
+ "@aethervtt/shard-assets": "0.2.1",
32
+ "@aethervtt/shard-core": "0.2.1",
33
+ "@aethervtt/shard-gpu": "0.2.1",
34
+ "@aethervtt/shard-platform": "0.2.1",
35
+ "@aethervtt/shard-render": "0.2.1",
36
+ "@aethervtt/shard-runtime": "0.2.1",
37
+ "@aethervtt/shard-texture": "0.2.1"
38
38
  },
39
39
  "repository": {
40
40
  "type": "git",
@@ -46,6 +46,6 @@
46
46
  "provenance": true
47
47
  },
48
48
  "shard": {
49
- "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
49
+ "buildSha": "772eb0d2b8a5fc499105708f4ae92c58788cc9ea"
50
50
  }
51
51
  }