@pixel-point/toolcraft 0.0.16 → 0.0.17

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.
Files changed (170) hide show
  1. package/package.json +1 -1
  2. package/src/generate-performance-iteration-execution.test.mjs +2 -2
  3. package/src/generate-test-demand-only-lifecycle.mjs +14 -3
  4. package/src/generate-test-dependency-sandbox.mjs +42 -5
  5. package/src/generate-test-doc-assertions.mjs +68 -18
  6. package/src/generate-test-doc-assertions.test.mjs +22 -0
  7. package/src/generate-test-file-assertions.mjs +43 -76
  8. package/src/generate-test-output-assertions.mjs +95 -0
  9. package/src/generate-test-package-assertions.mjs +6 -0
  10. package/src/generate-test-performance-boundary-assertions.mjs +45 -0
  11. package/src/generate-test-product-fixture.mjs +1 -0
  12. package/src/generate-test-product-fixture.test.mjs +18 -0
  13. package/src/generate-test-product-iteration.mjs +1 -0
  14. package/src/generate-test-product-iteration.test.mjs +1 -0
  15. package/src/generate-test-standalone-delivery-stages.mjs +14 -6
  16. package/src/generate.test.mjs +14 -0
  17. package/templates/runtime/contracts/component-contracts.choices.test.ts +12 -9
  18. package/templates/runtime/contracts/component-contracts.choices.ts +10 -9
  19. package/templates/runtime/contracts/component-contracts.runtime-acceptance.test.ts +17 -2
  20. package/templates/runtime/contracts/component-contracts.runtime-panels.test.ts +1 -1
  21. package/templates/runtime/contracts/component-contracts.runtime-shell.test.ts +1 -1
  22. package/templates/runtime/contracts/component-contracts.runtime.ts +3 -2
  23. package/templates/runtime/contracts/decision-contracts.test.ts +25 -4
  24. package/templates/runtime/contracts/decision-contracts.ts +3 -3
  25. package/templates/runtime/contracts/performance-verification-policy.test.ts +59 -6
  26. package/templates/runtime/contracts/performance-verification-policy.ts +5 -2
  27. package/templates/runtime/export/artifact-download.test.ts +71 -0
  28. package/templates/runtime/export/artifact-download.ts +80 -0
  29. package/templates/runtime/export/artifact-export-request.ts +23 -0
  30. package/templates/runtime/export/artifact-export-settings.test.ts +75 -0
  31. package/templates/runtime/export/artifact-export-settings.ts +96 -0
  32. package/templates/runtime/export/artifact-frame-renderer.test.ts +107 -0
  33. package/templates/runtime/export/artifact-frame-renderer.ts +96 -0
  34. package/templates/runtime/export/artifact-frame-state.test.ts +54 -0
  35. package/templates/runtime/export/artifact-frame-state.ts +44 -0
  36. package/templates/runtime/export/artifact-scene-frame.test.ts +84 -0
  37. package/templates/runtime/export/artifact-scene-frame.ts +118 -0
  38. package/templates/runtime/export/export-background.ts +65 -0
  39. package/templates/runtime/export/export-error.ts +43 -0
  40. package/templates/runtime/export/export.test.ts +6 -186
  41. package/templates/runtime/export/image-artifact-export.test.ts +111 -0
  42. package/templates/runtime/export/image-artifact-export.ts +145 -0
  43. package/templates/runtime/export/index.ts +14 -1
  44. package/templates/runtime/export/product-export-renderer.test.ts +77 -0
  45. package/templates/runtime/export/product-export-renderer.ts +63 -0
  46. package/templates/runtime/export/video-artifact-export.test.ts +166 -0
  47. package/templates/runtime/export/video-artifact-export.ts +164 -0
  48. package/templates/runtime/export/video-encoding-backend.ts +124 -0
  49. package/templates/runtime/export/video-encoding-policy.test.ts +68 -0
  50. package/templates/runtime/export/video-encoding-policy.ts +86 -0
  51. package/templates/runtime/export/video-frame-schedule.test.ts +58 -0
  52. package/templates/runtime/export/video-frame-schedule.ts +42 -0
  53. package/templates/runtime/index.ts +1 -0
  54. package/templates/runtime/react/app-shell/runtime-public-api-runtime.test.tsx +291 -0
  55. package/templates/runtime/react/app-shell/runtime-public-api-test-support.ts +21 -0
  56. package/templates/runtime/react/app-shell/runtime-public-api.test.tsx +4 -303
  57. package/templates/runtime/react/app-shell/toolcraft-app.scene-export.test.tsx +12 -27
  58. package/templates/runtime/react/app-shell/toolcraft-app.tsx +22 -4
  59. package/templates/runtime/react/app-shell/toolcraft-source-asset-context.test.tsx +16 -57
  60. package/templates/runtime/react/app-shell/toolcraft-source-asset-context.tsx +75 -434
  61. package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.test.ts +194 -0
  62. package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.ts +227 -0
  63. package/templates/runtime/react/app-shell/toolcraft-source-asset-provider-ownership.test.tsx +243 -0
  64. package/templates/runtime/react/app-shell/toolcraft-source-asset-test-support.ts +51 -0
  65. package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.test.ts +138 -0
  66. package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.ts +378 -0
  67. package/templates/runtime/react/controls-panel/__tests__/controls-panel.footer-actions.test.tsx +7 -12
  68. package/templates/runtime/react/controls-panel/__tests__/controls-panel.scene-export-actions.test.tsx +17 -83
  69. package/templates/runtime/react/controls-panel/actions/controls-panel-actions.ts +42 -84
  70. package/templates/runtime/react/controls-panel/actions/export-action-runner.ts +62 -0
  71. package/templates/runtime/react/panel-host/panel-host-runtime-placement.test.tsx +30 -0
  72. package/templates/runtime/react/panel-host/panel-host.tsx +4 -0
  73. package/templates/runtime/scene/scene-bounds.ts +1 -2
  74. package/templates/runtime/schema/canvas-render-scale.ts +62 -0
  75. package/templates/runtime/schema/controls-panel-section-id.test.ts +1 -1
  76. package/templates/runtime/schema/controls-panel-section-roundtrip.test.ts +3 -3
  77. package/templates/runtime/schema/define-toolcraft.setup-background.test.ts +1 -1
  78. package/templates/runtime/schema/define-toolcraft.setup-render-scale-timeline.test.ts +71 -3
  79. package/templates/runtime/schema/define-toolcraft.ts +1 -1
  80. package/templates/runtime/schema/schema-resolvers.ts +0 -56
  81. package/templates/runtime/schema/types.ts +4 -4
  82. package/templates/runtime/state/canvas-background-state.ts +6 -0
  83. package/templates/runtime/testing/performance-render-plan-integration.test.ts +64 -0
  84. package/templates/runtime/testing/performance-renderer-pipeline-validation.ts +18 -1
  85. package/templates/starter/AGENTS.md +8 -7
  86. package/templates/starter/NOTICE.md +6 -0
  87. package/templates/starter/docs/toolcraft/README.md +2 -1
  88. package/templates/starter/docs/toolcraft/acceptance-testing.md +8 -6
  89. package/templates/starter/docs/toolcraft/agent-worklog.md +3 -3
  90. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -3
  91. package/templates/starter/docs/toolcraft/core/media-upload.md +2 -2
  92. package/templates/starter/docs/toolcraft/core/performance.md +14 -3
  93. package/templates/starter/docs/toolcraft/core/runtime-boundary.md +3 -1
  94. package/templates/starter/docs/toolcraft/core/setup-export.md +16 -12
  95. package/templates/starter/docs/toolcraft/core/timeline-animation.md +4 -4
  96. package/templates/starter/docs/toolcraft/performance.md +20 -6
  97. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  98. package/templates/starter/docs/toolcraft/schema-reference.md +2 -6
  99. package/templates/starter/docs/toolcraft/workflow.md +7 -2
  100. package/templates/starter/e2e/app-browser-control-coverage.spec.ts +33 -0
  101. package/templates/starter/e2e/app-browser-infinity-canvas-evidence.spec.ts +43 -1
  102. package/templates/starter/e2e/app-browser-render-scale-evidence.spec.ts +60 -6
  103. package/templates/starter/e2e/app-browser-render-scale-requirements.spec.ts +77 -1
  104. package/templates/starter/e2e/app-browser-runtime-provenance.spec.ts +8 -0
  105. package/templates/starter/e2e/app-browser-runtime-reporter.spec.ts +35 -0
  106. package/templates/starter/e2e/app-browser-runtime-requirements.spec.ts +4 -1
  107. package/templates/starter/e2e/app-browser-semantic-evidence.spec.ts +0 -1
  108. package/templates/starter/e2e/app-browser-shell.spec.ts +13 -5
  109. package/templates/starter/e2e/app-performance-path-adapters.ts +8 -1
  110. package/templates/starter/e2e/app-performance.spec.ts +16 -9
  111. package/templates/starter/e2e/browser-infinity-canvas-evidence.ts +9 -330
  112. package/templates/starter/e2e/browser-infinity-canvas-unavailable-image-evidence.ts +370 -0
  113. package/templates/starter/e2e/browser-media-export-evidence.spec.ts +77 -0
  114. package/templates/starter/e2e/browser-media-export-evidence.ts +208 -0
  115. package/templates/starter/e2e/browser-render-scale-evidence.ts +37 -29
  116. package/templates/starter/e2e/browser-runtime-evidence-reporter.ts +4 -1
  117. package/templates/starter/e2e/browser-runtime-evidence-requirements.ts +43 -9
  118. package/templates/starter/e2e/browser-standard-timeline-evidence.ts +106 -0
  119. package/templates/starter/e2e/decoded-pixel-observation.spec.ts +74 -0
  120. package/templates/starter/e2e/decoded-pixel-observation.ts +227 -0
  121. package/templates/starter/e2e/export-artifact-helpers.ts +206 -25
  122. package/templates/starter/e2e/image-artifact-inspection.spec.ts +41 -0
  123. package/templates/starter/e2e/image-artifact-inspection.ts +99 -0
  124. package/templates/starter/e2e/performance-canvas-helpers.ts +22 -21
  125. package/templates/starter/e2e/performance-canvas-quality-guard.spec.ts +312 -0
  126. package/templates/starter/e2e/performance-canvas-quality-guard.ts +196 -0
  127. package/templates/starter/e2e/performance-canvas-quality-observer.ts +351 -0
  128. package/templates/starter/e2e/performance-control-layout-helpers.ts +2 -2
  129. package/templates/starter/e2e/performance-path-adapter-contract.ts +4 -4
  130. package/templates/starter/e2e/performance-path-helpers.spec.ts +203 -70
  131. package/templates/starter/e2e/performance-path-helpers.ts +210 -91
  132. package/templates/starter/e2e/performance-pipeline-evidence-continuity.spec.ts +76 -0
  133. package/templates/starter/e2e/performance-pipeline-evidence-test-fixtures.ts +11 -2
  134. package/templates/starter/e2e/performance-pipeline-invariants.ts +47 -4
  135. package/templates/starter/e2e/performance-render-scale-runner.spec.ts +140 -38
  136. package/templates/starter/e2e/video-artifact-inspection.spec.ts +64 -0
  137. package/templates/starter/e2e/video-artifact-inspection.ts +281 -0
  138. package/templates/starter/gitignore +1 -0
  139. package/templates/starter/package.json +1 -0
  140. package/templates/starter/scripts/check-toolcraft-docs.mjs +17 -0
  141. package/templates/starter/scripts/toolcraft-delivery-plan-authority.test.mjs +60 -0
  142. package/templates/starter/scripts/toolcraft-delivery-plan-errors.test.mjs +7 -2
  143. package/templates/starter/scripts/toolcraft-delivery-plan.mjs +7 -3
  144. package/templates/starter/scripts/toolcraft-product-boundary-ast.mjs +24 -2
  145. package/templates/starter/scripts/toolcraft-product-boundary.mjs +12 -0
  146. package/templates/starter/scripts/toolcraft-product-boundary.test.mjs +46 -0
  147. package/templates/starter/scripts/toolcraft-product-evidence-boundary.test.mjs +85 -20
  148. package/templates/starter/scripts/toolcraft-product-evidence-import-policy.mjs +43 -0
  149. package/templates/starter/scripts/toolcraft-product-export-boundary.mjs +168 -0
  150. package/templates/starter/scripts/toolcraft-typescript-analysis.d.mts +73 -0
  151. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade-test-helpers.mjs +318 -0
  152. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade.test.mjs +155 -206
  153. package/templates/starter/scripts/toolcraft-verification-inventory.mjs +1 -0
  154. package/templates/starter/scripts/toolcraft-verification-receipt.test.mjs +51 -0
  155. package/templates/starter/scripts/toolcraft-vite-env-activation.test.mjs +128 -59
  156. package/templates/starter/src/app/acceptance/export-artifact-coverage.ts +113 -0
  157. package/templates/starter/src/app/acceptance/output-background-rules.ts +1 -1
  158. package/templates/starter/src/app/acceptance/output-export.ts +13 -2
  159. package/templates/starter/src/app/acceptance/types.ts +7 -0
  160. package/templates/starter/src/app/acceptance/validate-coverage.ts +2 -1
  161. package/templates/starter/src/app/starter-acceptance.export-artifact.test.ts +131 -0
  162. package/templates/starter/src/app/starter-acceptance.keyframes.test.ts +14 -4
  163. package/templates/starter/src/app/starter-acceptance.output-export-rules.test.ts +42 -1
  164. package/templates/starter/src/app/starter-acceptance.ts +2 -0
  165. package/templates/starter/src/app/starter-performance-test-utils.ts +103 -0
  166. package/templates/starter/src/app/starter-performance.gates.test.ts +231 -278
  167. package/templates/starter/src/app/starter-performance.lifecycle.test.ts +261 -0
  168. package/templates/starter/src/app/test-evidence/browser-runtime-contract.ts +2 -0
  169. package/templates/starter/src/routes/index.tsx +1 -0
  170. package/templates/runtime/export/export.ts +0 -166
@@ -1,10 +1,11 @@
1
1
  import { expect, type Page } from "@playwright/test";
2
- import type { ToolcraftPerformancePath } from "@repo/toolcraft-runtime";
2
+ import { TOOLCRAFT_CANVAS_RENDER_SCALE } from "@repo/toolcraft-runtime";
3
3
 
4
+ import type { ToolcraftPerformancePipelinePhase } from "../src/app/test-evidence/browser-performance-contract";
4
5
  import type { ToolcraftRenderScaleState } from "../src/app/starter-acceptance";
5
6
  import { attachToolcraftBrowserRuntimeEvidence } from "./browser-runtime-evidence";
6
7
 
7
- type CanvasMetrics = Readonly<{
8
+ export type ToolcraftCanvasBackingMetrics = Readonly<{
8
9
  backingHeight: number;
9
10
  backingWidth: number;
10
11
  cssHeight: number;
@@ -12,6 +13,8 @@ type CanvasMetrics = Readonly<{
12
13
  devicePixelRatio: number;
13
14
  }>;
14
15
 
16
+ export type ToolcraftCanvasVisibleSizePolicy = "fixed" | "viewport-driven";
17
+
15
18
  export type ToolcraftRenderScaleStateTransition = Readonly<{
16
19
  run: () => Promise<void>;
17
20
  state: ToolcraftRenderScaleState;
@@ -19,20 +22,12 @@ export type ToolcraftRenderScaleStateTransition = Readonly<{
19
22
 
20
23
  export type ToolcraftRenderScaleBackingOptions = Readonly<{
21
24
  baselineCssSize?: Readonly<{ height: number; width: number }>;
22
- state?: ToolcraftRenderScaleState | "performance";
25
+ state?:
26
+ | ToolcraftRenderScaleState
27
+ | "performance-baseline"
28
+ | `performance-${ToolcraftPerformancePipelinePhase}`;
23
29
  }>;
24
30
 
25
- export function isToolcraftRenderScalePerformancePath(
26
- path: Pick<ToolcraftPerformancePath, "targets" | "workloadDimensions">,
27
- ): boolean {
28
- return (
29
- path.targets.includes("canvas.renderScale") ||
30
- path.workloadDimensions.some((dimension) =>
31
- /render[-_.]?scale|resolution[-_.]?scale/iu.test(dimension),
32
- )
33
- );
34
- }
35
-
36
31
  function assertPositiveFiniteScale(selectedScale: number): void {
37
32
  if (!Number.isFinite(selectedScale) || selectedScale <= 0) {
38
33
  throw new Error(
@@ -64,7 +59,7 @@ export function assertToolcraftRenderScaleBackingProofConfig(
64
59
  async function readCanvasMetrics(
65
60
  page: Page,
66
61
  canvasSelector: string,
67
- ): Promise<CanvasMetrics> {
62
+ ): Promise<ToolcraftCanvasBackingMetrics> {
68
63
  if (!canvasSelector.trim()) {
69
64
  throw new Error(
70
65
  "Toolcraft canvas backing-pixel checks require a non-empty canvas selector.",
@@ -95,8 +90,8 @@ async function readCanvasMetrics(
95
90
  });
96
91
  }
97
92
 
98
- function assertBackingMetrics(
99
- metrics: CanvasMetrics,
93
+ export function assertToolcraftCanvasBackingMetricsForRenderScale(
94
+ metrics: ToolcraftCanvasBackingMetrics,
100
95
  selectedScale: number,
101
96
  options: ToolcraftRenderScaleBackingOptions,
102
97
  ): void {
@@ -128,14 +123,16 @@ function assertBackingMetrics(
128
123
  metrics.cssWidth * metrics.devicePixelRatio * selectedScale;
129
124
  const expectedHeight =
130
125
  metrics.cssHeight * metrics.devicePixelRatio * selectedScale;
126
+ const widthError = Math.abs(metrics.backingWidth - expectedWidth);
127
+ const heightError = Math.abs(metrics.backingHeight - expectedHeight);
131
128
  expect(
132
- metrics.backingWidth,
133
- `${stateLabel} expected canvas backing width to honor Resolution scale ${selectedScale}.`,
134
- ).toBeGreaterThanOrEqual(Math.floor(expectedWidth - 1));
129
+ widthError,
130
+ `${stateLabel} expected exact canvas backing width within one physical pixel for Resolution scale ${selectedScale}.`,
131
+ ).toBeLessThanOrEqual(1);
135
132
  expect(
136
- metrics.backingHeight,
137
- `${stateLabel} expected canvas backing height to honor Resolution scale ${selectedScale}.`,
138
- ).toBeGreaterThanOrEqual(Math.floor(expectedHeight - 1));
133
+ heightError,
134
+ `${stateLabel} expected exact canvas backing height within one physical pixel for Resolution scale ${selectedScale}.`,
135
+ ).toBeLessThanOrEqual(1);
139
136
  }
140
137
 
141
138
  export async function expectToolcraftCanvasBackingPixelsForRenderScale(
@@ -146,7 +143,11 @@ export async function expectToolcraftCanvasBackingPixelsForRenderScale(
146
143
  ): Promise<Readonly<{ height: number; width: number }>> {
147
144
  assertPositiveFiniteScale(selectedScale);
148
145
  const metrics = await readCanvasMetrics(page, canvasSelector);
149
- assertBackingMetrics(metrics, selectedScale, options);
146
+ assertToolcraftCanvasBackingMetricsForRenderScale(
147
+ metrics,
148
+ selectedScale,
149
+ options,
150
+ );
150
151
  return { height: metrics.cssHeight, width: metrics.cssWidth };
151
152
  }
152
153
 
@@ -216,13 +217,17 @@ export async function expectToolcraftPerformanceRenderScaleBackingEvidence(
216
217
  page: Page,
217
218
  {
218
219
  canvasSelector,
220
+ baselineCssSize,
221
+ cssSizePolicy,
219
222
  pathId,
220
- selectedScale,
223
+ phase,
221
224
  target,
222
225
  }: Readonly<{
226
+ baselineCssSize: Readonly<{ height: number; width: number }>;
223
227
  canvasSelector: string;
228
+ cssSizePolicy: ToolcraftCanvasVisibleSizePolicy;
224
229
  pathId: string;
225
- selectedScale: number;
230
+ phase: ToolcraftPerformancePipelinePhase;
226
231
  target?: string;
227
232
  }>,
228
233
  ): Promise<void> {
@@ -234,12 +239,15 @@ export async function expectToolcraftPerformanceRenderScaleBackingEvidence(
234
239
  await expectToolcraftCanvasBackingPixelsForRenderScale(
235
240
  page,
236
241
  canvasSelector,
237
- selectedScale,
238
- { state: "performance" },
242
+ TOOLCRAFT_CANVAS_RENDER_SCALE.defaultValue,
243
+ {
244
+ ...(cssSizePolicy === "fixed" ? { baselineCssSize } : {}),
245
+ state: `performance-${phase}`,
246
+ },
239
247
  );
240
248
  await attachToolcraftBrowserRuntimeEvidence({
241
249
  evidenceType: "performance-render-scale",
242
- requirementId: pathId,
250
+ requirementId: `${pathId}#${phase}`,
243
251
  target,
244
252
  });
245
253
  }
@@ -173,7 +173,10 @@ export default class ToolcraftBrowserRuntimeEvidenceReporter implements Reporter
173
173
  );
174
174
  this.performanceRequirements =
175
175
  this.performanceRequirementsOverride ??
176
- deriveToolcraftPerformancePathRuntimeRequirements(this.canonicalPaths);
176
+ deriveToolcraftPerformancePathRuntimeRequirements(
177
+ this.canonicalPaths,
178
+ this.performanceSchema,
179
+ );
177
180
  }
178
181
 
179
182
  onEnd(_result: FullResult): { status: "failed" } | undefined {
@@ -3,6 +3,9 @@ import type {
3
3
  ToolcraftPerformancePath,
4
4
  } from "@repo/toolcraft-runtime";
5
5
 
6
+ import {
7
+ TOOLCRAFT_PERFORMANCE_PIPELINE_PHASES,
8
+ } from "../src/app/test-evidence/browser-performance-contract";
6
9
  import type { ToolcraftBrowserRuntimeRequirement } from "../src/app/test-evidence/browser-runtime-contract";
7
10
  import { schemaHasVideoExportPanelAction } from "../src/app/acceptance/output-export";
8
11
  import {
@@ -16,7 +19,6 @@ import {
16
19
  type ToolcraftTimelinePlaybackCoverage,
17
20
  TOOLCRAFT_REQUIRED_MODEL_IMPORT_COVERAGE,
18
21
  } from "../src/app/starter-acceptance";
19
- import { isToolcraftRenderScalePerformancePath } from "./browser-render-scale-evidence";
20
22
  import { getToolcraftPerformancePathTestName } from "./performance-path-helpers";
21
23
 
22
24
  type BrowserAcceptanceRequirementSource = Pick<
@@ -27,6 +29,7 @@ type BrowserAcceptanceRequirementSource = Pick<
27
29
  | "canvasHandle"
28
30
  | "controlPartCoverage"
29
31
  | "evidence"
32
+ | "exportArtifactCoverage"
30
33
  | "id"
31
34
  | "infinityCanvasCoverage"
32
35
  | "layerCoverage"
@@ -188,7 +191,25 @@ export function deriveToolcraftBrowserRuntimeRequirements(
188
191
  entry.orientationGizmoCoverage !== undefined;
189
192
  const evidenceTypes: ToolcraftBrowserRuntimeRequirement["evidenceType"][] = [];
190
193
  const baseEvidenceType = getAcceptanceEvidenceType(entry.evidence);
191
- if (baseEvidenceType) evidenceTypes.push(baseEvidenceType);
194
+ const exportArtifactCoverage = entry.exportArtifactCoverage
195
+ ? typeof entry.exportArtifactCoverage === "string"
196
+ ? [entry.exportArtifactCoverage]
197
+ : entry.exportArtifactCoverage
198
+ : [];
199
+ if (
200
+ baseEvidenceType &&
201
+ entry.renderScaleCoverage === undefined &&
202
+ exportArtifactCoverage.length === 0
203
+ ) {
204
+ evidenceTypes.push(baseEvidenceType);
205
+ }
206
+ for (const coverage of exportArtifactCoverage) {
207
+ evidenceTypes.push(
208
+ coverage === "all-required-image-export-behavior"
209
+ ? "image-export-artifact"
210
+ : "video-export-artifact",
211
+ );
212
+ }
192
213
 
193
214
  if (entry.canvasHandle && !hasOrientationCoverage) {
194
215
  evidenceTypes.push("canvas-handle-interaction");
@@ -321,7 +342,9 @@ export function deriveToolcraftBrowserRuntimeRequirements(
321
342
  });
322
343
  }
323
344
 
324
- const pathProductOutcomeInteractions = new Set<ToolcraftPerformancePath["interaction"]>([
345
+ const pathProductOutcomeInteractions = new Set<
346
+ ToolcraftPerformancePath["interaction"]
347
+ >([
325
348
  "control-change",
326
349
  "control-drag",
327
350
  "mask-drag",
@@ -332,6 +355,7 @@ const pathProductOutcomeInteractions = new Set<ToolcraftPerformancePath["interac
332
355
 
333
356
  export function deriveToolcraftPerformancePathRuntimeRequirements(
334
357
  paths: readonly ToolcraftPerformancePath[],
358
+ schema: ResolvedToolcraftAppSchema,
335
359
  ): ToolcraftBrowserRuntimeRequirement[] {
336
360
  return paths.flatMap((path) => {
337
361
  const evidenceTypes: ToolcraftBrowserRuntimeRequirement["evidenceType"][] = [
@@ -353,19 +377,29 @@ export function deriveToolcraftPerformancePathRuntimeRequirements(
353
377
  if (path.interaction === "animation-frame") {
354
378
  evidenceTypes.push("performance-animation-frames");
355
379
  }
356
- if (path.interaction === "viewport-drag" || path.interaction === "viewport-zoom") {
380
+ if (
381
+ path.interaction === "viewport-drag" ||
382
+ path.interaction === "viewport-zoom"
383
+ ) {
357
384
  evidenceTypes.push("performance-viewport");
358
385
  }
359
- if (isToolcraftRenderScalePerformancePath(path)) {
360
- evidenceTypes.push("performance-render-scale");
361
- }
362
-
363
386
  const target = path.targets.length === 1 ? path.targets[0] : undefined;
364
- return evidenceTypes.map((evidenceType) => ({
387
+ const requirements = evidenceTypes.map((evidenceType) => ({
365
388
  evidenceType,
366
389
  requirementId: path.id,
367
390
  target,
368
391
  testName: getToolcraftPerformancePathTestName(path),
369
392
  }));
393
+ if (schema.canvas.renderScale.enabled) {
394
+ for (const phase of TOOLCRAFT_PERFORMANCE_PIPELINE_PHASES) {
395
+ requirements.push({
396
+ evidenceType: "performance-render-scale",
397
+ requirementId: `${path.id}#${phase}`,
398
+ target,
399
+ testName: getToolcraftPerformancePathTestName(path),
400
+ });
401
+ }
402
+ }
403
+ return requirements;
370
404
  });
371
405
  }
@@ -0,0 +1,106 @@
1
+ import { expect } from "@playwright/test";
2
+
3
+ import { attachToolcraftBrowserRuntimeEvidence } from "./browser-runtime-evidence";
4
+ import {
5
+ getToolcraftBrowserProofPage,
6
+ type ToolcraftBrowserProofSession,
7
+ } from "./browser-proof-session";
8
+
9
+ async function readMarkerSignature(
10
+ page: Awaited<ReturnType<typeof getToolcraftBrowserProofPage>>,
11
+ markerSelector: string,
12
+ ): Promise<string> {
13
+ return page.locator(markerSelector).evaluate((element) => {
14
+ const node = element as HTMLElement;
15
+ return `${node.dataset.timelineProgress ?? "missing"}:${node.style.transform || node.style.left}`;
16
+ });
17
+ }
18
+
19
+ export async function expectToolcraftStandardTimelinePlayback(
20
+ session: ToolcraftBrowserProofSession,
21
+ options: Readonly<{
22
+ markerSelector: string;
23
+ requirementId: string;
24
+ }>,
25
+ ): Promise<void> {
26
+ const page = await getToolcraftBrowserProofPage(session);
27
+ const scrubber = page.getByRole("slider", { name: "Playback position" });
28
+ if (!(await scrubber.isVisible())) {
29
+ await page
30
+ .locator('[data-toolcraft-control-target="panels.timeline.extended"]')
31
+ .getByRole("switch")
32
+ .click();
33
+ await expect(scrubber).toBeVisible();
34
+ }
35
+ const pause = page.getByRole("button", { name: "Pause playback" });
36
+ if (await pause.isVisible()) await pause.click();
37
+
38
+ await scrubber.press("Home");
39
+ await expect(scrubber).toHaveAttribute("aria-valuenow", "0");
40
+ const initialStart = await readMarkerSignature(page, options.markerSelector);
41
+ await scrubber.press("End");
42
+ const initialEnd = await readMarkerSignature(page, options.markerSelector);
43
+ expect(initialEnd).toBe(initialStart);
44
+ await scrubber.press("ArrowLeft");
45
+ const scrubbed = await readMarkerSignature(page, options.markerSelector);
46
+ expect(scrubbed).not.toBe(initialStart);
47
+ await attachToolcraftBrowserRuntimeEvidence({
48
+ evidenceType: "timeline-scrub",
49
+ requirementId: options.requirementId,
50
+ target: "timeline.playback",
51
+ });
52
+ await attachToolcraftBrowserRuntimeEvidence({
53
+ evidenceType: "timeline-rendered-frame",
54
+ requirementId: options.requirementId,
55
+ target: "timeline.playback",
56
+ });
57
+ await attachToolcraftBrowserRuntimeEvidence({
58
+ evidenceType: "product-observable-change",
59
+ requirementId: options.requirementId,
60
+ target: "timeline.playback",
61
+ });
62
+
63
+ await page.getByRole("button", { name: "Edit timeline duration" }).click();
64
+ const durationEditor = page.getByRole("textbox", { name: "timeline duration" });
65
+ await durationEditor.fill("2s");
66
+ await durationEditor.press("Enter");
67
+ await expect(scrubber).toHaveAttribute("aria-valuemax", "2");
68
+ await scrubber.press("Home");
69
+ const resizedStart = await readMarkerSignature(page, options.markerSelector);
70
+ await scrubber.press("End");
71
+ const resizedEnd = await readMarkerSignature(page, options.markerSelector);
72
+ expect(resizedEnd).toBe(resizedStart);
73
+ await attachToolcraftBrowserRuntimeEvidence({
74
+ evidenceType: "timeline-duration",
75
+ requirementId: options.requirementId,
76
+ target: "timeline.playback",
77
+ });
78
+ await attachToolcraftBrowserRuntimeEvidence({
79
+ evidenceType: "timeline-loop",
80
+ requirementId: options.requirementId,
81
+ target: "timeline.playback",
82
+ });
83
+
84
+ await scrubber.press("Home");
85
+ await page.getByRole("button", { name: "Play playback" }).click();
86
+ await expect
87
+ .poll(() => scrubber.getAttribute("aria-valuenow"))
88
+ .not.toBe("0");
89
+ await page.getByRole("button", { name: "Pause playback" }).click();
90
+ const pausedTime = await scrubber.getAttribute("aria-valuenow");
91
+ const pausedSignature = await readMarkerSignature(page, options.markerSelector);
92
+ await page.waitForTimeout(100);
93
+ expect(await scrubber.getAttribute("aria-valuenow")).toBe(pausedTime);
94
+ expect(await readMarkerSignature(page, options.markerSelector)).toBe(
95
+ pausedSignature,
96
+ );
97
+ await page.getByRole("button", { name: "Play playback" }).click();
98
+ await expect
99
+ .poll(() => scrubber.getAttribute("aria-valuenow"))
100
+ .not.toBe(pausedTime);
101
+ await attachToolcraftBrowserRuntimeEvidence({
102
+ evidenceType: "timeline-pause-resume",
103
+ requirementId: options.requirementId,
104
+ target: "timeline.playback",
105
+ });
106
+ }
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ import {
4
+ assertToolcraftExpectedDecodedPixels,
5
+ observeToolcraftDecodedPixels,
6
+ validateToolcraftExpectedDecodedPixels,
7
+ } from "./decoded-pixel-observation";
8
+
9
+ function createCenteredFixture(): Uint8ClampedArray {
10
+ const pixels = new Uint8ClampedArray(64 * 64 * 4);
11
+ for (let y = 0; y < 64; y += 1) {
12
+ for (let x = 0; x < 64; x += 1) {
13
+ const offset = (y * 64 + x) * 4;
14
+ const product = x >= 16 && x < 48 && y >= 16 && y < 48;
15
+ pixels.set(product ? [255, 0, 0, 255] : [0, 0, 0, 255], offset);
16
+ }
17
+ }
18
+ return pixels;
19
+ }
20
+
21
+ test("decoded pixels produce stable semantic bounds and hash", async () => {
22
+ const observation = await observeToolcraftDecodedPixels({
23
+ backgroundRgba: [0, 0, 0, 255],
24
+ pixels: createCenteredFixture(),
25
+ sourceHeight: 64,
26
+ sourceWidth: 64,
27
+ });
28
+
29
+ expect(observation.decodedPixelHash).toMatch(/^[a-f0-9]{64}$/u);
30
+ expect(observation.nonBackgroundBounds).toEqual({
31
+ height: 0.5,
32
+ width: 0.5,
33
+ x: 0.25,
34
+ y: 0.25,
35
+ });
36
+ expect(observation.centroid).toEqual({ x: 0.5, y: 0.5 });
37
+ expect(observation.occupiedAreaRatio).toBe(0.25);
38
+ expect(() =>
39
+ assertToolcraftExpectedDecodedPixels({
40
+ expectedPixels: [
41
+ { rgba: [255, 0, 0, 255], xRatio: 0.5, yRatio: 0.5 },
42
+ ],
43
+ mediaType: "image/png",
44
+ observation,
45
+ }),
46
+ ).not.toThrow();
47
+ });
48
+
49
+ test("decoded pixel expectations cannot provide empty, invalid, or loosened checks", async () => {
50
+ const observation = await observeToolcraftDecodedPixels({
51
+ backgroundRgba: [0, 0, 0, 255],
52
+ pixels: createCenteredFixture(),
53
+ sourceHeight: 64,
54
+ sourceWidth: 64,
55
+ });
56
+
57
+ expect(() => validateToolcraftExpectedDecodedPixels([])).toThrow(
58
+ "at least one exact pixel sample",
59
+ );
60
+ expect(() =>
61
+ validateToolcraftExpectedDecodedPixels([
62
+ { rgba: [256, 0, 0, 255], xRatio: 2, yRatio: 0 } as never,
63
+ ]),
64
+ ).toThrow();
65
+ expect(() =>
66
+ assertToolcraftExpectedDecodedPixels({
67
+ expectedPixels: [
68
+ { rgba: [0, 255, 0, 255], xRatio: 0.5, yRatio: 0.5 },
69
+ ],
70
+ mediaType: "image/png",
71
+ observation,
72
+ }),
73
+ ).toThrow("fixed tolerance is 0");
74
+ });
@@ -0,0 +1,227 @@
1
+ import type { ToolcraftNormalizedPixelBounds } from "./export-artifact-helpers";
2
+
3
+ export const TOOLCRAFT_DECODED_OBSERVATION_SIZE = 64;
4
+ export const TOOLCRAFT_LOSSLESS_PIXEL_DISTANCE_TOLERANCE = 0;
5
+ export const TOOLCRAFT_LOSSY_PIXEL_DISTANCE_TOLERANCE = 48;
6
+ export const TOOLCRAFT_BACKGROUND_PIXEL_DISTANCE_THRESHOLD = 24;
7
+
8
+ export type ToolcraftExpectedDecodedPixel = Readonly<{
9
+ rgba: readonly [number, number, number, number];
10
+ xRatio: number;
11
+ yRatio: number;
12
+ }>;
13
+
14
+ export type ToolcraftExpectedDecodedVideoSample = Readonly<{
15
+ pixels: readonly ToolcraftExpectedDecodedPixel[];
16
+ timeSeconds: number;
17
+ }>;
18
+
19
+ export type ToolcraftDecodedPixelObservation = Readonly<{
20
+ centroid: Readonly<{ x: number; y: number }> | null;
21
+ decodedPixelHash: string;
22
+ nonBackgroundBounds: ToolcraftNormalizedPixelBounds | null;
23
+ normalizedPixels: Uint8ClampedArray;
24
+ occupiedAreaRatio: number;
25
+ }>;
26
+
27
+ function assertRgba(
28
+ rgba: readonly number[],
29
+ label: string,
30
+ ): asserts rgba is readonly [number, number, number, number] {
31
+ if (
32
+ rgba.length !== 4 ||
33
+ rgba.some(
34
+ (channel) =>
35
+ !Number.isSafeInteger(channel) || channel < 0 || channel > 255,
36
+ )
37
+ ) {
38
+ throw new Error(`${label} must contain four integer RGBA channels from 0 to 255.`);
39
+ }
40
+ }
41
+
42
+ function assertRatio(value: number, label: string): void {
43
+ if (!Number.isFinite(value) || value < 0 || value > 1) {
44
+ throw new Error(`${label} must be a finite normalized coordinate from 0 to 1.`);
45
+ }
46
+ }
47
+
48
+ export function validateToolcraftExpectedDecodedPixels(
49
+ pixels: readonly ToolcraftExpectedDecodedPixel[],
50
+ label = "Decoded pixel expectations",
51
+ ): void {
52
+ if (!Array.isArray(pixels) || pixels.length === 0) {
53
+ throw new Error(`${label} must contain at least one exact pixel sample.`);
54
+ }
55
+ for (const [index, pixel] of pixels.entries()) {
56
+ assertRatio(pixel.xRatio, `${label}[${index}].xRatio`);
57
+ assertRatio(pixel.yRatio, `${label}[${index}].yRatio`);
58
+ assertRgba(pixel.rgba, `${label}[${index}].rgba`);
59
+ }
60
+ }
61
+
62
+ export function getToolcraftRgbaDistance(
63
+ left: readonly number[],
64
+ right: readonly number[],
65
+ ): number {
66
+ return Math.sqrt(
67
+ left.reduce((sum, channel, index) => {
68
+ const delta = channel - (right[index] ?? 0);
69
+ return sum + delta * delta;
70
+ }, 0),
71
+ );
72
+ }
73
+
74
+ function normalizePixels({
75
+ pixels,
76
+ sourceHeight,
77
+ sourceWidth,
78
+ }: Readonly<{
79
+ pixels: Uint8ClampedArray;
80
+ sourceHeight: number;
81
+ sourceWidth: number;
82
+ }>): Uint8ClampedArray {
83
+ if (
84
+ !Number.isSafeInteger(sourceWidth) ||
85
+ sourceWidth <= 0 ||
86
+ !Number.isSafeInteger(sourceHeight) ||
87
+ sourceHeight <= 0 ||
88
+ pixels.length !== sourceWidth * sourceHeight * 4
89
+ ) {
90
+ throw new Error("Decoded pixel input must match positive source dimensions.");
91
+ }
92
+ if (
93
+ sourceWidth === TOOLCRAFT_DECODED_OBSERVATION_SIZE &&
94
+ sourceHeight === TOOLCRAFT_DECODED_OBSERVATION_SIZE
95
+ ) {
96
+ return new Uint8ClampedArray(pixels);
97
+ }
98
+
99
+ const normalized = new Uint8ClampedArray(
100
+ TOOLCRAFT_DECODED_OBSERVATION_SIZE * TOOLCRAFT_DECODED_OBSERVATION_SIZE * 4,
101
+ );
102
+ for (let y = 0; y < TOOLCRAFT_DECODED_OBSERVATION_SIZE; y += 1) {
103
+ const sourceY = Math.min(
104
+ sourceHeight - 1,
105
+ Math.floor(((y + 0.5) / TOOLCRAFT_DECODED_OBSERVATION_SIZE) * sourceHeight),
106
+ );
107
+ for (let x = 0; x < TOOLCRAFT_DECODED_OBSERVATION_SIZE; x += 1) {
108
+ const sourceX = Math.min(
109
+ sourceWidth - 1,
110
+ Math.floor(((x + 0.5) / TOOLCRAFT_DECODED_OBSERVATION_SIZE) * sourceWidth),
111
+ );
112
+ const sourceOffset = (sourceY * sourceWidth + sourceX) * 4;
113
+ const outputOffset =
114
+ (y * TOOLCRAFT_DECODED_OBSERVATION_SIZE + x) * 4;
115
+ normalized.set(pixels.subarray(sourceOffset, sourceOffset + 4), outputOffset);
116
+ }
117
+ }
118
+ return normalized;
119
+ }
120
+
121
+ async function hashPixels(pixels: Uint8ClampedArray): Promise<string> {
122
+ const source = new Uint8Array(
123
+ pixels.buffer,
124
+ pixels.byteOffset,
125
+ pixels.byteLength,
126
+ );
127
+ const hash = await crypto.subtle.digest("SHA-256", source);
128
+ return Array.from(new Uint8Array(hash), (byte) =>
129
+ byte.toString(16).padStart(2, "0"),
130
+ ).join("");
131
+ }
132
+
133
+ export async function observeToolcraftDecodedPixels({
134
+ backgroundRgba,
135
+ pixels,
136
+ sourceHeight,
137
+ sourceWidth,
138
+ }: Readonly<{
139
+ backgroundRgba: readonly [number, number, number, number];
140
+ pixels: Uint8ClampedArray;
141
+ sourceHeight: number;
142
+ sourceWidth: number;
143
+ }>): Promise<ToolcraftDecodedPixelObservation> {
144
+ assertRgba(backgroundRgba, "backgroundRgba");
145
+ const normalizedPixels = normalizePixels({
146
+ pixels,
147
+ sourceHeight,
148
+ sourceWidth,
149
+ });
150
+ let count = 0;
151
+ let minX = TOOLCRAFT_DECODED_OBSERVATION_SIZE;
152
+ let minY = TOOLCRAFT_DECODED_OBSERVATION_SIZE;
153
+ let maxX = -1;
154
+ let maxY = -1;
155
+ let sumX = 0;
156
+ let sumY = 0;
157
+
158
+ for (let y = 0; y < TOOLCRAFT_DECODED_OBSERVATION_SIZE; y += 1) {
159
+ for (let x = 0; x < TOOLCRAFT_DECODED_OBSERVATION_SIZE; x += 1) {
160
+ const offset = (y * TOOLCRAFT_DECODED_OBSERVATION_SIZE + x) * 4;
161
+ const pixel = normalizedPixels.subarray(offset, offset + 4);
162
+ if (
163
+ getToolcraftRgbaDistance(pixel, backgroundRgba) <=
164
+ TOOLCRAFT_BACKGROUND_PIXEL_DISTANCE_THRESHOLD
165
+ ) {
166
+ continue;
167
+ }
168
+ count += 1;
169
+ minX = Math.min(minX, x);
170
+ minY = Math.min(minY, y);
171
+ maxX = Math.max(maxX, x);
172
+ maxY = Math.max(maxY, y);
173
+ sumX += x + 0.5;
174
+ sumY += y + 0.5;
175
+ }
176
+ }
177
+
178
+ const size = TOOLCRAFT_DECODED_OBSERVATION_SIZE;
179
+ return Object.freeze({
180
+ centroid:
181
+ count === 0
182
+ ? null
183
+ : Object.freeze({ x: sumX / count / size, y: sumY / count / size }),
184
+ decodedPixelHash: await hashPixels(normalizedPixels),
185
+ nonBackgroundBounds:
186
+ count === 0
187
+ ? null
188
+ : Object.freeze({
189
+ height: (maxY - minY + 1) / size,
190
+ width: (maxX - minX + 1) / size,
191
+ x: minX / size,
192
+ y: minY / size,
193
+ }),
194
+ normalizedPixels,
195
+ occupiedAreaRatio: count / (size * size),
196
+ });
197
+ }
198
+
199
+ export function assertToolcraftExpectedDecodedPixels({
200
+ expectedPixels,
201
+ mediaType,
202
+ observation,
203
+ }: Readonly<{
204
+ expectedPixels: readonly ToolcraftExpectedDecodedPixel[];
205
+ mediaType: "image/jpeg" | "image/png" | "video/mp4" | "video/webm";
206
+ observation: ToolcraftDecodedPixelObservation;
207
+ }>): void {
208
+ validateToolcraftExpectedDecodedPixels(expectedPixels);
209
+ const tolerance =
210
+ mediaType === "image/png"
211
+ ? TOOLCRAFT_LOSSLESS_PIXEL_DISTANCE_TOLERANCE
212
+ : TOOLCRAFT_LOSSY_PIXEL_DISTANCE_TOLERANCE;
213
+ const size = TOOLCRAFT_DECODED_OBSERVATION_SIZE;
214
+
215
+ for (const expected of expectedPixels) {
216
+ const x = Math.min(size - 1, Math.floor(expected.xRatio * size));
217
+ const y = Math.min(size - 1, Math.floor(expected.yRatio * size));
218
+ const offset = (y * size + x) * 4;
219
+ const actual = observation.normalizedPixels.subarray(offset, offset + 4);
220
+ const distance = getToolcraftRgbaDistance(actual, expected.rgba);
221
+ if (distance > tolerance) {
222
+ throw new Error(
223
+ `Decoded pixel at (${expected.xRatio}, ${expected.yRatio}) differs from expected RGBA by ${distance.toFixed(2)}; fixed tolerance is ${tolerance}.`,
224
+ );
225
+ }
226
+ }
227
+ }