@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
@@ -0,0 +1,312 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+
3
+ import { expectToolcraftCanvasBackingPixelsForRenderScale } from "./browser-render-scale-evidence";
4
+ import { createToolcraftPerformanceCanvasQualityGuard } from "./performance-canvas-quality-guard";
5
+
6
+ const canvasSelector = "[data-quality-guard-canvas]";
7
+
8
+ async function installCanvas(page: Page): Promise<void> {
9
+ await page.setContent(`
10
+ <canvas
11
+ data-quality-guard-canvas
12
+ style="display:block;width:120px;height:80px"
13
+ ></canvas>
14
+ `);
15
+ await setBackingScale(page, 2);
16
+ }
17
+
18
+ async function setBackingScale(page: Page, scale: number): Promise<void> {
19
+ await page.locator(canvasSelector).evaluate((canvas, nextScale) => {
20
+ if (!(canvas instanceof HTMLCanvasElement)) {
21
+ throw new Error("Quality guard fixture must target a canvas.");
22
+ }
23
+ const rect = canvas.getBoundingClientRect();
24
+ canvas.width = Math.round(rect.width * window.devicePixelRatio * nextScale);
25
+ canvas.height = Math.round(rect.height * window.devicePixelRatio * nextScale);
26
+ }, scale);
27
+ }
28
+
29
+ async function createGuard(
30
+ page: Page,
31
+ cssSizePolicy: "fixed" | "viewport-driven" = "fixed",
32
+ ) {
33
+ const baselineCssSize = await expectToolcraftCanvasBackingPixelsForRenderScale(
34
+ page,
35
+ canvasSelector,
36
+ 2,
37
+ { state: "performance-baseline" },
38
+ );
39
+ return createToolcraftPerformanceCanvasQualityGuard(page, {
40
+ baselineCssSize,
41
+ canvasSelector,
42
+ cssSizePolicy,
43
+ pathId: "performance-path:quality-guard",
44
+ });
45
+ }
46
+
47
+ test("quality guard rejects a transient same-document 1x clamp", async ({
48
+ page,
49
+ }) => {
50
+ await installCanvas(page);
51
+ const guard = await createGuard(page);
52
+
53
+ try {
54
+ await expect(
55
+ guard.runPhase("cold", async () => {
56
+ await setBackingScale(page, 1);
57
+ await page.evaluate(
58
+ () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
59
+ );
60
+ await setBackingScale(page, 2);
61
+ }),
62
+ ).rejects.toThrow(/cold.*backing width/iu);
63
+ } finally {
64
+ await guard.dispose();
65
+ }
66
+ });
67
+
68
+ test("quality guard rejects an immediate 1x clamp followed by canvas replacement", async ({
69
+ page,
70
+ }) => {
71
+ await installCanvas(page);
72
+ const guard = await createGuard(page);
73
+
74
+ try {
75
+ await expect(
76
+ guard.runPhase("cold", async () => {
77
+ await page.locator(canvasSelector).evaluate((canvas) => {
78
+ if (!(canvas instanceof HTMLCanvasElement)) {
79
+ throw new Error("Quality guard fixture must target a canvas.");
80
+ }
81
+ const rect = canvas.getBoundingClientRect();
82
+ canvas.width = Math.round(rect.width * window.devicePixelRatio);
83
+ canvas.height = Math.round(rect.height * window.devicePixelRatio);
84
+ const replacement = canvas.cloneNode(false) as HTMLCanvasElement;
85
+ replacement.width = Math.round(
86
+ rect.width * window.devicePixelRatio * 2,
87
+ );
88
+ replacement.height = Math.round(
89
+ rect.height * window.devicePixelRatio * 2,
90
+ );
91
+ canvas.replaceWith(replacement);
92
+ });
93
+ }),
94
+ ).rejects.toThrow(/cold.*backing width/iu);
95
+ } finally {
96
+ await guard.dispose();
97
+ }
98
+ });
99
+
100
+ test("quality guard rejects a one-frame CSS transform on a deep ancestor", async ({
101
+ page,
102
+ }) => {
103
+ await installCanvas(page);
104
+ await page.locator(canvasSelector).evaluate((canvas) => {
105
+ let child: Element = canvas;
106
+ for (let depth = 0; depth < 8; depth += 1) {
107
+ const wrapper = document.createElement("div");
108
+ wrapper.dataset.qualityGuardDepth = String(depth);
109
+ child.replaceWith(wrapper);
110
+ wrapper.append(child);
111
+ child = wrapper;
112
+ }
113
+ });
114
+ const guard = await createGuard(page);
115
+
116
+ try {
117
+ await expect(
118
+ guard.runPhase("warm", async () => {
119
+ await page.locator(canvasSelector).evaluate(async (canvas) => {
120
+ if (!(canvas instanceof HTMLCanvasElement)) {
121
+ throw new Error("Quality guard fixture must target a canvas.");
122
+ }
123
+ const ancestor = document.querySelector(
124
+ '[data-quality-guard-depth="7"]',
125
+ );
126
+ if (!(ancestor instanceof HTMLElement)) {
127
+ throw new Error("Quality guard fixture requires a canvas ancestor.");
128
+ }
129
+ ancestor.style.transform = "scale(.5)";
130
+ await new Promise<void>((resolve) =>
131
+ requestAnimationFrame(() => resolve()),
132
+ );
133
+ ancestor.style.transform = "";
134
+ });
135
+ }),
136
+ ).rejects.toThrow(/warm.*preserve.*CSS width/iu);
137
+ } finally {
138
+ await guard.dispose();
139
+ }
140
+ });
141
+
142
+ test("quality guard rejects a transient navigation-time 1x clamp", async ({
143
+ page,
144
+ }) => {
145
+ await installCanvas(page);
146
+ const guard = await createGuard(page);
147
+ const destination = `data:text/html,${encodeURIComponent(`
148
+ <canvas data-quality-guard-canvas style="display:block;width:120px;height:80px"></canvas>
149
+ <script>
150
+ const canvas = document.querySelector('[data-quality-guard-canvas]');
151
+ const rect = canvas.getBoundingClientRect();
152
+ canvas.width = Math.round(rect.width * devicePixelRatio);
153
+ canvas.height = Math.round(rect.height * devicePixelRatio);
154
+ requestAnimationFrame(() => {
155
+ canvas.width = Math.round(rect.width * devicePixelRatio * 2);
156
+ canvas.height = Math.round(rect.height * devicePixelRatio * 2);
157
+ });
158
+ <\/script>
159
+ `)}`;
160
+
161
+ try {
162
+ await expect(
163
+ guard.runPhase("warm", async () => {
164
+ await page.goto(destination);
165
+ await page.evaluate(
166
+ () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
167
+ );
168
+ }),
169
+ ).rejects.toThrow(/warm.*backing width/iu);
170
+ } finally {
171
+ await guard.dispose();
172
+ }
173
+ });
174
+
175
+ test("quality guard accepts stable exact 2x backing", async ({ page }) => {
176
+ await installCanvas(page);
177
+ const guard = await createGuard(page);
178
+
179
+ try {
180
+ await expect(
181
+ guard.runPhase("sustained", async () => {
182
+ await setBackingScale(page, 2);
183
+ return "stable";
184
+ }),
185
+ ).resolves.toBe("stable");
186
+ } finally {
187
+ await guard.dispose();
188
+ }
189
+ });
190
+
191
+ test("quality guard accepts exact backing while viewport zoom changes visible size", async ({
192
+ page,
193
+ }) => {
194
+ await installCanvas(page);
195
+ const guard = await createGuard(page, "viewport-driven");
196
+
197
+ try {
198
+ await expect(
199
+ guard.runPhase("cold", async () => {
200
+ await page.locator(canvasSelector).evaluate((canvas) => {
201
+ if (!(canvas instanceof HTMLCanvasElement)) {
202
+ throw new Error("Quality guard fixture must target a canvas.");
203
+ }
204
+ canvas.style.width = "132px";
205
+ canvas.width = Math.round(132 * window.devicePixelRatio * 2);
206
+ });
207
+ }),
208
+ ).resolves.toBeUndefined();
209
+ } finally {
210
+ await guard.dispose();
211
+ }
212
+ });
213
+
214
+ test("quality guard rejects a transient clamp while viewport zoom changes visible size", async ({
215
+ page,
216
+ }) => {
217
+ await installCanvas(page);
218
+ const guard = await createGuard(page, "viewport-driven");
219
+
220
+ try {
221
+ await expect(
222
+ guard.runPhase("cold", async () => {
223
+ await page.locator(canvasSelector).evaluate((canvas) => {
224
+ if (!(canvas instanceof HTMLCanvasElement)) {
225
+ throw new Error("Quality guard fixture must target a canvas.");
226
+ }
227
+ canvas.style.width = "132px";
228
+ canvas.width = Math.round(132 * window.devicePixelRatio);
229
+ canvas.width = Math.round(132 * window.devicePixelRatio * 2);
230
+ });
231
+ }),
232
+ ).rejects.toThrow(/backing width/iu);
233
+ } finally {
234
+ await guard.dispose();
235
+ }
236
+ });
237
+
238
+ test("quality guard preserves the original phase callback failure", async ({
239
+ page,
240
+ }) => {
241
+ await installCanvas(page);
242
+ const guard = await createGuard(page);
243
+
244
+ try {
245
+ await expect(
246
+ guard.runPhase("cold", async () => {
247
+ await setBackingScale(page, 1);
248
+ throw new Error("primary callback failure");
249
+ }),
250
+ ).rejects.toThrow("primary callback failure");
251
+ } finally {
252
+ await guard.dispose();
253
+ }
254
+ });
255
+
256
+ test("quality guard ignores matching 1x canvases in child frames", async ({
257
+ page,
258
+ }) => {
259
+ await installCanvas(page);
260
+ const guard = await createGuard(page);
261
+
262
+ try {
263
+ await expect(
264
+ guard.runPhase("sustained", async () => {
265
+ await page.evaluate(
266
+ () =>
267
+ new Promise<void>((resolve) => {
268
+ const iframe = document.createElement("iframe");
269
+ iframe.srcdoc = `<canvas data-quality-guard-canvas style="display:block;width:120px;height:80px" width="120" height="80"></canvas>`;
270
+ iframe.addEventListener("load", () => resolve(), { once: true });
271
+ document.body.append(iframe);
272
+ }),
273
+ );
274
+ await page.waitForTimeout(50);
275
+ }),
276
+ ).resolves.toBeUndefined();
277
+ const childFrame = page.frames().find((frame) => frame !== page.mainFrame());
278
+ expect(childFrame).toBeDefined();
279
+ expect(
280
+ await childFrame!.evaluate(() =>
281
+ Object.getOwnPropertyNames(globalThis).filter((key) =>
282
+ key.startsWith("__toolcraftCanvasQualityObserver"),
283
+ ),
284
+ ),
285
+ ).toEqual([]);
286
+ } finally {
287
+ await guard.dispose();
288
+ }
289
+ });
290
+
291
+ test("disposed guard does not recreate observer state after navigation", async ({
292
+ page,
293
+ }) => {
294
+ await installCanvas(page);
295
+ const guard = await createGuard(page);
296
+ await guard.dispose();
297
+
298
+ await page.goto(
299
+ `data:text/html,${encodeURIComponent(
300
+ '<canvas data-quality-guard-canvas style="display:block;width:120px;height:80px" width="120" height="80"></canvas>',
301
+ )}`,
302
+ );
303
+ await page.waitForTimeout(50);
304
+
305
+ expect(
306
+ await page.evaluate(() =>
307
+ Object.getOwnPropertyNames(globalThis).filter((key) =>
308
+ key.startsWith("__toolcraftCanvasQualityObserver"),
309
+ ),
310
+ ),
311
+ ).toEqual([]);
312
+ });
@@ -0,0 +1,196 @@
1
+ import type { Page } from "@playwright/test";
2
+ import { TOOLCRAFT_CANVAS_RENDER_SCALE } from "@repo/toolcraft-runtime";
3
+
4
+ import type {
5
+ ToolcraftPerformancePipelinePhase,
6
+ } from "../src/app/test-evidence/browser-performance-contract";
7
+ import {
8
+ assertToolcraftCanvasBackingMetricsForRenderScale,
9
+ expectToolcraftCanvasBackingPixelsForRenderScale,
10
+ type ToolcraftCanvasVisibleSizePolicy,
11
+ } from "./browser-render-scale-evidence";
12
+ import {
13
+ installToolcraftPerformanceCanvasQualityObserver,
14
+ isToolcraftPerformanceCanvasQualityHandshake,
15
+ isToolcraftPerformanceCanvasQualityObservation,
16
+ type ToolcraftPerformanceCanvasQualityObservation,
17
+ } from "./performance-canvas-quality-observer";
18
+
19
+ export type ToolcraftPerformanceCanvasQualityGuard = Readonly<{
20
+ dispose: () => Promise<void>;
21
+ runPhase: <T>(
22
+ phase: ToolcraftPerformancePipelinePhase,
23
+ run: () => Promise<T>,
24
+ ) => Promise<T>;
25
+ }>;
26
+
27
+ let sessionSequence = 0;
28
+
29
+ async function flushCanvasQualityObserver(
30
+ page: Page,
31
+ stateKey: string,
32
+ ): Promise<void> {
33
+ await page.evaluate(async (key) => {
34
+ await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
35
+ const state = Reflect.get(globalThis, key) as
36
+ | { flush: () => Promise<void> }
37
+ | undefined;
38
+ if (!state) {
39
+ throw new Error("Toolcraft canvas quality guard observer is unavailable.");
40
+ }
41
+ await state.flush();
42
+ }, stateKey);
43
+ }
44
+
45
+ async function beginCanvasQualityObservation(
46
+ page: Page,
47
+ stateKey: string,
48
+ ): Promise<void> {
49
+ await page.evaluate((key) => {
50
+ const state = Reflect.get(globalThis, key) as
51
+ | { begin: () => void }
52
+ | undefined;
53
+ if (!state) {
54
+ throw new Error("Toolcraft canvas quality guard observer is unavailable.");
55
+ }
56
+ state.begin();
57
+ }, stateKey);
58
+ }
59
+
60
+ export async function createToolcraftPerformanceCanvasQualityGuard(
61
+ page: Page,
62
+ options: Readonly<{
63
+ baselineCssSize: Readonly<{ height: number; width: number }>;
64
+ canvasSelector: string;
65
+ cssSizePolicy: ToolcraftCanvasVisibleSizePolicy;
66
+ pathId: string;
67
+ }>,
68
+ ): Promise<ToolcraftPerformanceCanvasQualityGuard> {
69
+ if (!options.pathId.trim()) {
70
+ throw new Error(
71
+ "Toolcraft canvas quality guard requires a non-empty performance pathId.",
72
+ );
73
+ }
74
+ if (!options.canvasSelector.trim()) {
75
+ throw new Error(
76
+ "Toolcraft canvas quality guard requires a non-empty canvasSelector.",
77
+ );
78
+ }
79
+ if (
80
+ !Number.isFinite(options.baselineCssSize.width) ||
81
+ !Number.isFinite(options.baselineCssSize.height) ||
82
+ options.baselineCssSize.width <= 0 ||
83
+ options.baselineCssSize.height <= 0
84
+ ) {
85
+ throw new Error(
86
+ `Toolcraft canvas quality guard for "${options.pathId}" requires positive finite baseline CSS dimensions.`,
87
+ );
88
+ }
89
+ const sessionId = ++sessionSequence;
90
+ const bindingName = `__toolcraftCanvasQualityObservation${sessionId}`;
91
+ const stateKey = `__toolcraftCanvasQualityObserver${sessionId}`;
92
+ let activePhase: ToolcraftPerformancePipelinePhase | undefined;
93
+ let disposed = false;
94
+ let observations: ToolcraftPerformanceCanvasQualityObservation[] = [];
95
+
96
+ await page.exposeBinding(bindingName, (source, observation: unknown) => {
97
+ if (source.frame !== page.mainFrame()) return false;
98
+ if (isToolcraftPerformanceCanvasQualityHandshake(observation)) {
99
+ return !disposed;
100
+ }
101
+ if (disposed || activePhase === undefined) return false;
102
+ if (!isToolcraftPerformanceCanvasQualityObservation(observation)) {
103
+ throw new Error(
104
+ "Toolcraft canvas quality guard received an invalid observation.",
105
+ );
106
+ }
107
+ observations.push(observation);
108
+ return true;
109
+ });
110
+ const installerOptions = {
111
+ baselineCssSize: options.baselineCssSize,
112
+ bindingName,
113
+ canvasSelector: options.canvasSelector,
114
+ stateKey,
115
+ } as const;
116
+ await page.addInitScript(
117
+ installToolcraftPerformanceCanvasQualityObserver,
118
+ installerOptions,
119
+ );
120
+ await page.evaluate(
121
+ installToolcraftPerformanceCanvasQualityObserver,
122
+ installerOptions,
123
+ );
124
+
125
+ return Object.freeze({
126
+ dispose: async () => {
127
+ if (disposed) return;
128
+ disposed = true;
129
+ activePhase = undefined;
130
+ observations = [];
131
+ if (page.isClosed()) return;
132
+ await page.evaluate((key) => {
133
+ const state = Reflect.get(globalThis, key) as
134
+ | { dispose: () => void }
135
+ | undefined;
136
+ state?.dispose();
137
+ Reflect.deleteProperty(globalThis, key);
138
+ }, stateKey);
139
+ },
140
+ runPhase: async <T>(
141
+ phase: ToolcraftPerformancePipelinePhase,
142
+ run: () => Promise<T>,
143
+ ): Promise<T> => {
144
+ if (disposed) {
145
+ throw new Error("Toolcraft canvas quality guard has been disposed.");
146
+ }
147
+ if (activePhase !== undefined) {
148
+ throw new Error(
149
+ `Toolcraft canvas quality guard cannot start "${phase}" while "${activePhase}" is active.`,
150
+ );
151
+ }
152
+ observations = [];
153
+ await beginCanvasQualityObservation(page, stateKey);
154
+ activePhase = phase;
155
+ let result: T;
156
+ try {
157
+ result = await run();
158
+ } catch (error) {
159
+ activePhase = undefined;
160
+ observations = [];
161
+ throw error;
162
+ }
163
+
164
+ try {
165
+ await flushCanvasQualityObserver(page, stateKey);
166
+ for (const observation of observations) {
167
+ assertToolcraftCanvasBackingMetricsForRenderScale(
168
+ observation.metrics,
169
+ TOOLCRAFT_CANVAS_RENDER_SCALE.defaultValue,
170
+ {
171
+ ...(options.cssSizePolicy === "fixed"
172
+ ? { baselineCssSize: options.baselineCssSize }
173
+ : {}),
174
+ state: `performance-${phase}`,
175
+ },
176
+ );
177
+ }
178
+ await expectToolcraftCanvasBackingPixelsForRenderScale(
179
+ page,
180
+ options.canvasSelector,
181
+ TOOLCRAFT_CANVAS_RENDER_SCALE.defaultValue,
182
+ {
183
+ ...(options.cssSizePolicy === "fixed"
184
+ ? { baselineCssSize: options.baselineCssSize }
185
+ : {}),
186
+ state: `performance-${phase}`,
187
+ },
188
+ );
189
+ return result;
190
+ } finally {
191
+ activePhase = undefined;
192
+ observations = [];
193
+ }
194
+ },
195
+ });
196
+ }