@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,12 +1,52 @@
1
- export type ToolcraftExportArtifactInspection = {
1
+ export type ToolcraftNormalizedPixelBounds = Readonly<{
2
+ height: number;
3
+ width: number;
4
+ x: number;
5
+ y: number;
6
+ }>;
7
+
8
+ export type ToolcraftBinaryArtifactInspection = Readonly<{
2
9
  byteLength: number;
3
10
  contentHash?: string;
4
11
  durationMs?: number;
5
12
  frameCount?: number;
6
13
  height?: number;
14
+ kind?: "binary";
7
15
  mediaType?: string;
8
16
  width?: number;
9
- };
17
+ }>;
18
+
19
+ export type ToolcraftImageArtifactInspection = Readonly<{
20
+ byteLength: number;
21
+ decodedPixelHash: string;
22
+ height: number;
23
+ kind: "image";
24
+ mediaType: "image/jpeg" | "image/png";
25
+ nonBackgroundBounds: ToolcraftNormalizedPixelBounds | null;
26
+ width: number;
27
+ }>;
28
+
29
+ export type ToolcraftVideoPacketTiming = Readonly<{
30
+ durationSeconds: number;
31
+ timeSeconds: number;
32
+ }>;
33
+
34
+ export type ToolcraftVideoArtifactInspection = Readonly<{
35
+ byteLength: number;
36
+ durationMs: number;
37
+ frameCount: number;
38
+ height: number;
39
+ kind: "video";
40
+ mediaType: "video/mp4" | "video/webm";
41
+ packetTimings: readonly ToolcraftVideoPacketTiming[];
42
+ samplePixelHashes: readonly string[];
43
+ width: number;
44
+ }>;
45
+
46
+ export type ToolcraftExportArtifactInspection =
47
+ | ToolcraftBinaryArtifactInspection
48
+ | ToolcraftImageArtifactInspection
49
+ | ToolcraftVideoArtifactInspection;
10
50
 
11
51
  export type ToolcraftExportArtifactInspectionResult =
12
52
  | ToolcraftExportArtifactInspection
@@ -33,13 +73,46 @@ function assertPositiveInteger(
33
73
  field: string,
34
74
  requirementId: string,
35
75
  ): asserts value is number {
76
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
77
+ throw new Error(
78
+ `Export requirement "${requirementId}" must report a positive ${field} integer.`,
79
+ );
80
+ }
81
+ }
82
+
83
+ function validateNormalizedPixelBounds(
84
+ value: unknown,
85
+ requirementId: string,
86
+ ): void {
87
+ if (value === null) return;
88
+ if (!isRecord(value)) {
89
+ throw new Error(
90
+ `Export requirement "${requirementId}" must report normalized nonBackgroundBounds or null.`,
91
+ );
92
+ }
93
+ const { height, width, x, y } = value;
94
+ for (const [field, coordinate] of Object.entries({ height, width, x, y })) {
95
+ if (
96
+ typeof coordinate !== "number" ||
97
+ !Number.isFinite(coordinate) ||
98
+ coordinate < 0 ||
99
+ coordinate > 1 ||
100
+ ((field === "height" || field === "width") && coordinate === 0)
101
+ ) {
102
+ throw new Error(
103
+ `Export requirement "${requirementId}" must report normalized nonBackgroundBounds.${field}.`,
104
+ );
105
+ }
106
+ }
36
107
  if (
37
- typeof value !== "number" ||
38
- !Number.isSafeInteger(value) ||
39
- value <= 0
108
+ typeof x === "number" &&
109
+ typeof y === "number" &&
110
+ typeof width === "number" &&
111
+ typeof height === "number" &&
112
+ (x + width > 1 || y + height > 1)
40
113
  ) {
41
114
  throw new Error(
42
- `Export requirement "${requirementId}" must report a positive ${field} integer.`,
115
+ `Export requirement "${requirementId}" nonBackgroundBounds must remain inside the normalized artifact.`,
43
116
  );
44
117
  }
45
118
  }
@@ -64,25 +137,12 @@ export function assertToolcraftProducedArtifact(
64
137
  }
65
138
  }
66
139
 
67
- export function validateToolcraftExportArtifactInspection(
68
- observation: unknown,
140
+ function validateInspection(
141
+ inspection: Record<string, unknown>,
69
142
  requirementId: string,
70
- ): ToolcraftExportArtifactInspectionResult {
71
- const observations = Array.isArray(observation) ? observation : [observation];
72
- if (observations.length === 0) {
73
- throw new Error(
74
- `Export requirement "${requirementId}" must inspect at least one artifact.`,
75
- );
76
- }
77
-
78
- for (const inspection of observations) {
79
- if (!isRecord(inspection) || !("byteLength" in inspection)) {
80
- throw new Error(
81
- `Export requirement "${requirementId}" must return a typed artifact inspection with byteLength.`,
82
- );
83
- }
84
-
85
- assertPositiveInteger(inspection.byteLength, "byteLength", requirementId);
143
+ ): void {
144
+ assertPositiveInteger(inspection.byteLength, "byteLength", requirementId);
145
+ if (inspection.kind === undefined || inspection.kind === "binary") {
86
146
  if (inspection.durationMs !== undefined) {
87
147
  assertPositiveFiniteNumber(
88
148
  inspection.durationMs,
@@ -105,6 +165,108 @@ export function validateToolcraftExportArtifactInspection(
105
165
  );
106
166
  }
107
167
  }
168
+ return;
169
+ }
170
+
171
+ if (inspection.kind === "image") {
172
+ assertPositiveInteger(inspection.width, "width", requirementId);
173
+ assertPositiveInteger(inspection.height, "height", requirementId);
174
+ if (
175
+ inspection.mediaType !== "image/png" &&
176
+ inspection.mediaType !== "image/jpeg"
177
+ ) {
178
+ throw new Error(
179
+ `Export requirement "${requirementId}" must report a supported image mediaType.`,
180
+ );
181
+ }
182
+ if (
183
+ typeof inspection.decodedPixelHash !== "string" ||
184
+ !inspection.decodedPixelHash.trim()
185
+ ) {
186
+ throw new Error(
187
+ `Export requirement "${requirementId}" must hash decoded image pixels.`,
188
+ );
189
+ }
190
+ validateNormalizedPixelBounds(
191
+ inspection.nonBackgroundBounds,
192
+ requirementId,
193
+ );
194
+ return;
195
+ }
196
+
197
+ if (inspection.kind === "video") {
198
+ assertPositiveInteger(inspection.width, "width", requirementId);
199
+ assertPositiveInteger(inspection.height, "height", requirementId);
200
+ assertPositiveInteger(inspection.frameCount, "frameCount", requirementId);
201
+ assertPositiveFiniteNumber(inspection.durationMs, "durationMs", requirementId);
202
+ if (
203
+ inspection.mediaType !== "video/mp4" &&
204
+ inspection.mediaType !== "video/webm"
205
+ ) {
206
+ throw new Error(
207
+ `Export requirement "${requirementId}" must report a supported video mediaType.`,
208
+ );
209
+ }
210
+ if (
211
+ !Array.isArray(inspection.packetTimings) ||
212
+ inspection.packetTimings.length !== inspection.frameCount
213
+ ) {
214
+ throw new Error(
215
+ `Export requirement "${requirementId}" must report every real encoded packet timing.`,
216
+ );
217
+ }
218
+ for (const [index, timing] of inspection.packetTimings.entries()) {
219
+ if (
220
+ !isRecord(timing) ||
221
+ typeof timing.timeSeconds !== "number" ||
222
+ !Number.isFinite(timing.timeSeconds) ||
223
+ timing.timeSeconds < 0 ||
224
+ typeof timing.durationSeconds !== "number" ||
225
+ !Number.isFinite(timing.durationSeconds) ||
226
+ timing.durationSeconds <= 0
227
+ ) {
228
+ throw new Error(
229
+ `Export requirement "${requirementId}" must report valid timing for encoded packet ${index}.`,
230
+ );
231
+ }
232
+ }
233
+ if (
234
+ !Array.isArray(inspection.samplePixelHashes) ||
235
+ inspection.samplePixelHashes.length === 0 ||
236
+ inspection.samplePixelHashes.some(
237
+ (hash) => typeof hash !== "string" || !hash.trim(),
238
+ )
239
+ ) {
240
+ throw new Error(
241
+ `Export requirement "${requirementId}" must hash decoded video samples.`,
242
+ );
243
+ }
244
+ return;
245
+ }
246
+
247
+ throw new Error(
248
+ `Export requirement "${requirementId}" returned an unknown artifact inspection kind.`,
249
+ );
250
+ }
251
+
252
+ export function validateToolcraftExportArtifactInspection(
253
+ observation: unknown,
254
+ requirementId: string,
255
+ ): ToolcraftExportArtifactInspectionResult {
256
+ const observations = Array.isArray(observation) ? observation : [observation];
257
+ if (observations.length === 0) {
258
+ throw new Error(
259
+ `Export requirement "${requirementId}" must inspect at least one artifact.`,
260
+ );
261
+ }
262
+
263
+ for (const inspection of observations) {
264
+ if (!isRecord(inspection) || !("byteLength" in inspection)) {
265
+ throw new Error(
266
+ `Export requirement "${requirementId}" must return a typed artifact inspection with byteLength.`,
267
+ );
268
+ }
269
+ validateInspection(inspection, requirementId);
108
270
  }
109
271
 
110
272
  return observation as ToolcraftExportArtifactInspectionResult;
@@ -115,12 +277,31 @@ export function getToolcraftSemanticArtifactSignature(
115
277
  requirementId: string,
116
278
  ): string {
117
279
  validateToolcraftExportArtifactInspection(inspection, requirementId);
280
+ if (inspection.kind === "image") {
281
+ return JSON.stringify({
282
+ decodedPixelHash: inspection.decodedPixelHash,
283
+ height: inspection.height,
284
+ mediaType: inspection.mediaType,
285
+ nonBackgroundBounds: inspection.nonBackgroundBounds,
286
+ width: inspection.width,
287
+ });
288
+ }
289
+ if (inspection.kind === "video") {
290
+ return JSON.stringify({
291
+ durationMs: inspection.durationMs,
292
+ frameCount: inspection.frameCount,
293
+ height: inspection.height,
294
+ mediaType: inspection.mediaType,
295
+ packetTimings: inspection.packetTimings,
296
+ samplePixelHashes: inspection.samplePixelHashes,
297
+ width: inspection.width,
298
+ });
299
+ }
118
300
  if (!inspection.contentHash?.trim()) {
119
301
  throw new Error(
120
302
  `Export requirement "${requirementId}" must report contentHash from decoded product output for semantic comparison.`,
121
303
  );
122
304
  }
123
-
124
305
  return JSON.stringify({
125
306
  contentHash: inspection.contentHash,
126
307
  durationMs: inspection.durationMs,
@@ -0,0 +1,41 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import { expect, test, type Download } from "@playwright/test";
4
+
5
+ import { inspectToolcraftImageDownload } from "./image-artifact-inspection";
6
+
7
+ test("image inspection hashes decoded pixels and reports product bounds", async ({
8
+ page,
9
+ }, testInfo) => {
10
+ await page.goto("/");
11
+ const base64 = await page.evaluate(() => {
12
+ const canvas = document.createElement("canvas");
13
+ canvas.width = 64;
14
+ canvas.height = 32;
15
+ const context = canvas.getContext("2d");
16
+ if (!context) throw new Error("Missing test canvas context.");
17
+ context.fillStyle = "#000000";
18
+ context.fillRect(0, 0, 64, 32);
19
+ context.fillStyle = "#FF0000";
20
+ context.fillRect(16, 8, 32, 16);
21
+ return canvas.toDataURL("image/png").split(",")[1]!;
22
+ });
23
+ const artifactPath = testInfo.outputPath("decoded-fixture.png");
24
+ await fs.writeFile(artifactPath, Buffer.from(base64, "base64"));
25
+ const download = { path: async () => artifactPath } as Download;
26
+
27
+ const result = await inspectToolcraftImageDownload({
28
+ backgroundRgba: [0, 0, 0, 255],
29
+ download,
30
+ page,
31
+ });
32
+
33
+ expect(result.inspection).toMatchObject({
34
+ height: 32,
35
+ kind: "image",
36
+ mediaType: "image/png",
37
+ nonBackgroundBounds: { height: 0.5, width: 0.5, x: 0.25, y: 0.25 },
38
+ width: 64,
39
+ });
40
+ expect(result.inspection.decodedPixelHash).toMatch(/^[a-f0-9]{64}$/u);
41
+ });
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ import type { Download, Page } from "@playwright/test";
4
+
5
+ import {
6
+ observeToolcraftDecodedPixels,
7
+ type ToolcraftDecodedPixelObservation,
8
+ } from "./decoded-pixel-observation";
9
+ import type { ToolcraftImageArtifactInspection } from "./export-artifact-helpers";
10
+
11
+ export type ToolcraftInspectedImageArtifact = Readonly<{
12
+ inspection: ToolcraftImageArtifactInspection;
13
+ observation: ToolcraftDecodedPixelObservation;
14
+ }>;
15
+
16
+ function detectImageMediaType(
17
+ bytes: Uint8Array,
18
+ ): "image/jpeg" | "image/png" {
19
+ if (
20
+ bytes.length >= 8 &&
21
+ bytes[0] === 0x89 &&
22
+ bytes[1] === 0x50 &&
23
+ bytes[2] === 0x4e &&
24
+ bytes[3] === 0x47
25
+ ) {
26
+ return "image/png";
27
+ }
28
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8) {
29
+ return "image/jpeg";
30
+ }
31
+ throw new Error("Downloaded artifact is not a decodable PNG or JPEG image.");
32
+ }
33
+
34
+ export async function inspectToolcraftImageDownload({
35
+ backgroundRgba,
36
+ download,
37
+ page,
38
+ }: Readonly<{
39
+ backgroundRgba: readonly [number, number, number, number];
40
+ download: Download;
41
+ page: Page;
42
+ }>): Promise<ToolcraftInspectedImageArtifact> {
43
+ const downloadPath = await download.path();
44
+ if (!downloadPath) {
45
+ throw new Error("Playwright did not expose the downloaded image path.");
46
+ }
47
+ const bytes = await fs.readFile(downloadPath);
48
+ if (bytes.byteLength === 0) {
49
+ throw new Error("Downloaded image artifact is empty.");
50
+ }
51
+ const mediaType = detectImageMediaType(bytes);
52
+ const decoded = await page.evaluate(
53
+ async ({ base64, mediaType: decodedMediaType }) => {
54
+ const binary = atob(base64);
55
+ const data = Uint8Array.from(binary, (character) =>
56
+ character.charCodeAt(0),
57
+ );
58
+ const bitmap = await createImageBitmap(
59
+ new Blob([data], { type: decodedMediaType }),
60
+ );
61
+ try {
62
+ const canvas = document.createElement("canvas");
63
+ canvas.width = 64;
64
+ canvas.height = 64;
65
+ const context = canvas.getContext("2d", { willReadFrequently: true });
66
+ if (!context) throw new Error("Unable to inspect exported image pixels.");
67
+ context.imageSmoothingEnabled = false;
68
+ context.drawImage(bitmap, 0, 0, 64, 64);
69
+ return {
70
+ height: bitmap.height,
71
+ pixels: Array.from(context.getImageData(0, 0, 64, 64).data),
72
+ width: bitmap.width,
73
+ };
74
+ } finally {
75
+ bitmap.close();
76
+ }
77
+ },
78
+ { base64: bytes.toString("base64"), mediaType },
79
+ );
80
+ const observation = await observeToolcraftDecodedPixels({
81
+ backgroundRgba,
82
+ pixels: Uint8ClampedArray.from(decoded.pixels),
83
+ sourceHeight: 64,
84
+ sourceWidth: 64,
85
+ });
86
+
87
+ return Object.freeze({
88
+ inspection: Object.freeze({
89
+ byteLength: bytes.byteLength,
90
+ decodedPixelHash: observation.decodedPixelHash,
91
+ height: decoded.height,
92
+ kind: "image",
93
+ mediaType,
94
+ nonBackgroundBounds: observation.nonBackgroundBounds,
95
+ width: decoded.width,
96
+ }),
97
+ observation,
98
+ });
99
+ }
@@ -38,22 +38,22 @@ export async function dragToolcraftCanvasViewport(
38
38
 
39
39
  export async function zoomToolcraftCanvasViewport(
40
40
  page: Page,
41
- repetitions = 2,
42
- options: { pathId?: string } = {},
41
+ options: {
42
+ direction: "in" | "out";
43
+ pathId?: string;
44
+ repetitions?: number;
45
+ },
43
46
  ): Promise<void> {
44
47
  const zoomIn = page.getByRole("button", { name: "Zoom in" });
45
48
  const zoomOut = page.getByRole("button", { name: "Zoom out" });
49
+ const control = options.direction === "in" ? zoomIn : zoomOut;
50
+ await expect(
51
+ control,
52
+ `Toolcraft zoom-${options.direction} control should be visible`,
53
+ ).toBeVisible();
46
54
 
47
- await expect(zoomIn, "Toolcraft zoom-in control should be visible").toBeVisible();
48
- await expect(zoomOut, "Toolcraft zoom-out control should be visible").toBeVisible();
49
-
50
- for (let index = 0; index < repetitions; index += 1) {
51
- await zoomIn.click();
52
- await waitForToolcraftAnimationFrames(page, 2);
53
- }
54
-
55
- for (let index = 0; index < repetitions; index += 1) {
56
- await zoomOut.click();
55
+ for (let index = 0; index < (options.repetitions ?? 1); index += 1) {
56
+ await control.click();
57
57
  await waitForToolcraftAnimationFrames(page, 2);
58
58
  }
59
59
  if (options.pathId) {
@@ -70,17 +70,18 @@ export async function readToolcraftCanvasViewport(page: Page): Promise<{
70
70
  zoom: number;
71
71
  }> {
72
72
  return page.evaluate(() => {
73
- const canvas = document.querySelector("[data-toolcraft-editable-canvas]");
74
- const style = canvas ? window.getComputedStyle(canvas) : null;
75
- const zoomText =
76
- canvas?.getAttribute("data-canvas-zoom") ??
77
- style?.getPropertyValue("--canvas-zoom") ??
78
- "1";
73
+ const world = document.querySelector("[data-toolcraft-canvas-world]");
79
74
 
80
75
  return {
81
- offsetX: Number(canvas?.getAttribute("data-canvas-offset-x") ?? 0),
82
- offsetY: Number(canvas?.getAttribute("data-canvas-offset-y") ?? 0),
83
- zoom: Number.parseFloat(zoomText) || 1,
76
+ offsetX: Number(
77
+ world?.getAttribute("data-toolcraft-canvas-offset-x") ?? 0,
78
+ ),
79
+ offsetY: Number(
80
+ world?.getAttribute("data-toolcraft-canvas-offset-y") ?? 0,
81
+ ),
82
+ zoom: Number.parseFloat(
83
+ world?.getAttribute("data-toolcraft-canvas-zoom") ?? "100",
84
+ ) || 100,
84
85
  };
85
86
  });
86
87
  }