@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,1390 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+ import {
7
+ collectToolcraftPerformanceSensitiveControls,
8
+ collectToolcraftUnclassifiedPerformanceControls,
9
+ defineToolcraft,
10
+ validateToolcraftPerformanceCoverage,
11
+ } from "@repo/toolcraft-runtime";
12
+
13
+ import { starterPerformance } from "./starter-performance";
14
+ import { starterSchema } from "./starter-schema";
15
+
16
+ const currentFileName = basename(fileURLToPath(import.meta.url));
17
+ const appDir = dirname(fileURLToPath(import.meta.url));
18
+ const srcDir = join(appDir, "..");
19
+ const routesDir = join(appDir, "../routes");
20
+ const e2eDir = join(appDir, "../../e2e");
21
+ const projectDir = join(appDir, "../..");
22
+
23
+ function stripJsComments(source: string): string {
24
+ return source
25
+ .replace(/\/\*[\s\S]*?\*\//g, "")
26
+ .replace(/(^|[^:])\/\/.*$/gm, "$1");
27
+ }
28
+
29
+ function readFiles(rootDir: string, matcher: RegExp): string {
30
+ const chunks: string[] = [];
31
+
32
+ function visit(currentDir: string) {
33
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
34
+ const filePath = join(currentDir, entry.name);
35
+
36
+ if (entry.isDirectory()) {
37
+ if (!["dist", "node_modules", "toolcraft"].includes(entry.name)) {
38
+ visit(filePath);
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (
44
+ entry.isFile() &&
45
+ matcher.test(entry.name) &&
46
+ !/\.(test|spec)\.[cm]?[jt]sx?$/.test(entry.name) &&
47
+ !/^(?:starter-|app-)(?:acceptance|performance)\.ts$/.test(entry.name)
48
+ ) {
49
+ chunks.push(readFileSync(filePath, "utf8"));
50
+ }
51
+ }
52
+ }
53
+
54
+ visit(rootDir);
55
+ return chunks.join("\n");
56
+ }
57
+
58
+ function readSiblingAppTestSources(): string {
59
+ return readdirSync(appDir)
60
+ .filter((fileName) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(fileName))
61
+ .filter((fileName) => fileName !== currentFileName)
62
+ .map((fileName) => readFileSync(join(appDir, fileName), "utf8"))
63
+ .map(stripJsComments)
64
+ .join("\n");
65
+ }
66
+
67
+ function readBrowserTestSources(): string {
68
+ return readdirSync(e2eDir)
69
+ .filter((fileName) => /\.(test|spec)\.[cm]?[jt]sx?$/.test(fileName))
70
+ .map((fileName) => readFileSync(join(e2eDir, fileName), "utf8"))
71
+ .map(stripJsComments)
72
+ .join("\n");
73
+ }
74
+
75
+ function readMarkdownFiles(rootDir: string): string {
76
+ if (!existsSync(rootDir)) {
77
+ return "";
78
+ }
79
+
80
+ const chunks: string[] = [];
81
+
82
+ function visit(currentDir: string) {
83
+ for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
84
+ const filePath = join(currentDir, entry.name);
85
+
86
+ if (entry.isDirectory()) {
87
+ if (!["dist", "node_modules", "toolcraft"].includes(entry.name)) {
88
+ visit(filePath);
89
+ }
90
+ continue;
91
+ }
92
+
93
+ if (entry.isFile() && /\.mdx?$/i.test(entry.name)) {
94
+ chunks.push(readFileSync(filePath, "utf8"));
95
+ }
96
+ }
97
+ }
98
+
99
+ visit(rootDir);
100
+ return chunks.join("\n");
101
+ }
102
+
103
+ function readProjectDecisionSources(): string {
104
+ return stripJsComments(
105
+ [
106
+ readMarkdownFiles(join(projectDir, "docs")),
107
+ readMarkdownFiles(join(projectDir, "specs")),
108
+ readMarkdownFiles(join(projectDir, "plans")),
109
+ ].join("\n"),
110
+ );
111
+ }
112
+
113
+ function projectDocsIncludeRendererTechniqueDecision(): boolean {
114
+ const decisionSources = readProjectDecisionSources();
115
+
116
+ return [
117
+ /Renderer Technique Decision Matrix/i,
118
+ /sourceRepresentation/,
119
+ /productRepresentation/,
120
+ /previewRenderer/,
121
+ /exportRenderer/,
122
+ /rendererWorkload/,
123
+ /rendererStrategy/,
124
+ /whyNotAlternativeStrategies/,
125
+ /fidelityRisks/,
126
+ /performanceRisks/,
127
+ ].every((pattern) => pattern.test(decisionSources));
128
+ }
129
+
130
+ function projectDocsIncludeRendererLayerInventory(): boolean {
131
+ const decisionSources = readProjectDecisionSources();
132
+
133
+ return (
134
+ /Renderer Layer Inventory|rendererTechnique\.layers|layer inventory/i.test(decisionSources) &&
135
+ /backgroundLayer|productForegroundLayer|editingHandlesLayer|exportComposite|product-foreground/i.test(
136
+ decisionSources,
137
+ )
138
+ );
139
+ }
140
+
141
+ function projectDocsExplainRendererAlternatives(): boolean {
142
+ const decisionSources = readProjectDecisionSources();
143
+
144
+ return (
145
+ /whyNotAlternativeStrategies/.test(decisionSources) &&
146
+ /alternative|strategy|renderer/i.test(decisionSources) &&
147
+ /text-output|vector-output|pixel-output|rendererWorkload/.test(decisionSources) &&
148
+ /exportRenderer|export\/copy|product-quality/i.test(decisionSources)
149
+ );
150
+ }
151
+
152
+ function sourceUsesCustomRenderer(): boolean {
153
+ const routeSources = stripJsComments(readFiles(routesDir, /\.(ts|tsx)$/));
154
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
155
+
156
+ return (
157
+ /canvasContent\s*=/.test(routeSources) ||
158
+ /renderDefaultCanvasMedia=\{false\}/.test(routeSources) ||
159
+ /useToolcraft(Value)?\(/.test(appSources) ||
160
+ /getContext\(["']2d["']\)|webgl|webgpu|OffscreenCanvas|ImageData/.test(appSources)
161
+ );
162
+ }
163
+
164
+ function sourceUsesHardcodedOutputBackgroundColor(
165
+ source = stripJsComments(readFiles(srcDir, /\.(ts|tsx|css)$/)),
166
+ ): boolean {
167
+ const canvasFillPattern =
168
+ /(?:ctx|context|canvasContext)\.fillStyle\s*=\s*["']#[0-9a-fA-F]{3,8}["'][\s\S]{0,240}\.fillRect\s*\(/;
169
+ const outputCssBackgroundPattern =
170
+ /\.(?:[a-z0-9_-]*(?:canvas|renderer|preview|output|product)[a-z0-9_-]*)\s*{[^}]*background(?:-color)?\s*:\s*#[0-9a-fA-F]{3,8}/i;
171
+
172
+ return canvasFillPattern.test(source) || outputCssBackgroundPattern.test(source);
173
+ }
174
+
175
+ function schemaHasOutputBackgroundColorControl(): boolean {
176
+ return (starterSchema.panels.controls?.sections ?? []).some((section) =>
177
+ Object.values(section.controls).some((control) => {
178
+ if (control.type !== "color") {
179
+ return false;
180
+ }
181
+
182
+ const searchText = [
183
+ section.title,
184
+ typeof control.label === "string" ? control.label : "",
185
+ control.target,
186
+ ].join(" ");
187
+
188
+ return /\b(background|backdrop|scene|canvas)\b/i.test(searchText);
189
+ }),
190
+ );
191
+ }
192
+
193
+ function projectDocsIncludeFixedBackgroundDecision(): boolean {
194
+ return /fixedBackgroundReason|fixed background|non-editable background|not user-editable background|reference-defined background|product-defined background/i.test(
195
+ readProjectDecisionSources(),
196
+ );
197
+ }
198
+
199
+ function sourceUsesCpuPixelLoop(): boolean {
200
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
201
+
202
+ return (
203
+ /new\s+ImageData\s*\(/.test(appSources) ||
204
+ /\.createImageData\s*\(/.test(appSources) ||
205
+ /\.getImageData\s*\(/.test(appSources) ||
206
+ /\.putImageData\s*\(/.test(appSources)
207
+ );
208
+ }
209
+
210
+ function sourceUsesGpuRenderer(): boolean {
211
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
212
+
213
+ return /getContext\(["']webgl2?["']\)|navigator\.gpu|GPUCanvasContext/.test(appSources);
214
+ }
215
+
216
+ function sourceUsesWebGlLifecycleGuard(): boolean {
217
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
218
+
219
+ return (
220
+ /useEffect\s*\(/.test(appSources) ||
221
+ /useLayoutEffect\s*\(/.test(appSources) ||
222
+ /useMemo\s*\(/.test(appSources) ||
223
+ /useRef\s*\(/.test(appSources) ||
224
+ /class\s+\w+Renderer/.test(appSources)
225
+ );
226
+ }
227
+
228
+ function sourceCreatesWebGlContextInComponentRender(): boolean {
229
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
230
+ const componentRenderPattern =
231
+ /function\s+[A-Z]\w*\s*\([^)]*\)\s*{(?![\s\S]{0,600}use(?:Layout)?Effect\s*\()[\s\S]{0,600}\.getContext\(["']webgl2?["']\)/;
232
+
233
+ return componentRenderPattern.test(appSources);
234
+ }
235
+
236
+ function sourceMayUploadTextureFromTimelineDrivenEffect(): boolean {
237
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
238
+ const timelineDrivenTextureUploadPattern =
239
+ /use(?:Layout)?Effect\s*\(\s*\(\)\s*=>\s*{[\s\S]*?(?:texImage2D\s*\(|\.setImage\s*\()[\s\S]*?}\s*,\s*\[[\s\S]*?(?:settings|state\.timeline|currentTimeSeconds|keyframeGroups)[\s\S]*?\]\s*\)/;
240
+
241
+ return timelineDrivenTextureUploadPattern.test(appSources);
242
+ }
243
+
244
+ function sourceResyncsTimelineDurationFromRuntimeDuration(): boolean {
245
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
246
+ const durationResyncPattern =
247
+ /use(?:Layout)?Effect\s*\(\s*\(\)\s*=>\s*{[\s\S]*?timeline\.setDuration[\s\S]*?}\s*,\s*\[[\s\S]*state\.timeline\.durationSeconds[\s\S]*\]\s*\)/;
248
+
249
+ return durationResyncPattern.test(appSources);
250
+ }
251
+
252
+ function sourceUsesLowResolutionPreviewUpscale(source = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/))): boolean {
253
+ const lowResolutionPreviewPattern =
254
+ /maxPreviewPixels|previewPixelBudget|previewScale|previewRatio|lowRes|lowResolution|downsample/i;
255
+ const scaledDrawImagePattern =
256
+ /\.drawImage\s*\([\s\S]{0,240}(?:outputWidth|outputHeight|state\.canvas\.size|canvas\.width|canvas\.height)[\s\S]{0,240}\)/;
257
+
258
+ return lowResolutionPreviewPattern.test(source) || scaledDrawImagePattern.test(source);
259
+ }
260
+
261
+ function browserTestsAssertNativePreviewResolution(): boolean {
262
+ const browserTestSources = readBrowserTestSources();
263
+
264
+ return (
265
+ /previewWidth|previewHeight|clientWidth|clientHeight|getBoundingClientRect/.test(
266
+ browserTestSources,
267
+ ) &&
268
+ /outputWidth|outputHeight|state\.canvas\.size|canvas\.size|toHaveAttribute/.test(
269
+ browserTestSources,
270
+ )
271
+ );
272
+ }
273
+
274
+ function sourceUsesAnimationFrameWithoutCleanup(): boolean {
275
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
276
+
277
+ return /requestAnimationFrame\s*\(/.test(appSources) && !/cancelAnimationFrame\s*\(/.test(appSources);
278
+ }
279
+
280
+ function sourceUsesDirectStorageApi(): boolean {
281
+ const appSources = stripJsComments(readFiles(srcDir, /\.(ts|tsx)$/));
282
+
283
+ return /\b(?:localStorage|sessionStorage)\s*\./.test(appSources);
284
+ }
285
+
286
+ describe("Toolcraft template app performance coverage", () => {
287
+ it("publishes a sequential browser performance gate", () => {
288
+ const packageJson = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")) as {
289
+ scripts?: Record<string, string>;
290
+ };
291
+
292
+ expect(
293
+ packageJson.scripts?.["test:browser:perf"],
294
+ "Generated apps must expose a sequential browser performance gate so perf budgets are measured without parallel e2e noise.",
295
+ ).toBe(
296
+ 'playwright install chromium && playwright test e2e/app-performance.spec.ts --workers=1 && playwright test --grep "browser perf:" --workers=1 --pass-with-no-tests',
297
+ );
298
+ expect(packageJson.scripts?.["verify:quick"]).toBe("pnpm ai:check && pnpm test");
299
+ expect(packageJson.scripts?.["verify:ui"]).toBe("pnpm test:browser");
300
+ expect(packageJson.scripts?.["verify:perf"]).toBe("pnpm test:browser:perf");
301
+ expect(packageJson.scripts?.["verify:final"]).toBe(
302
+ "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser && pnpm test:browser:perf",
303
+ );
304
+ });
305
+
306
+ it("requires valid performance coverage for declared workload scenarios", () => {
307
+ expect(validateToolcraftPerformanceCoverage(starterSchema, starterPerformance)).toEqual([]);
308
+ });
309
+
310
+ it("requires every visible control to classify its performance role", () => {
311
+ const unclassifiedControls =
312
+ collectToolcraftUnclassifiedPerformanceControls(starterSchema);
313
+
314
+ expect(
315
+ unclassifiedControls,
316
+ "Every visible non-action control must declare performanceRole as workload or responsiveness so AI cannot skip the performance decision.",
317
+ ).toEqual([]);
318
+ });
319
+
320
+ it("requires generated custom renderers to opt into the performance matrix", () => {
321
+ if (sourceUsesCustomRenderer()) {
322
+ expect(starterPerformance.usesCustomRenderer).toBe(true);
323
+ }
324
+ });
325
+
326
+ it("requires custom renderer apps to document the renderer technique decision", () => {
327
+ if (!starterPerformance.usesCustomRenderer) {
328
+ return;
329
+ }
330
+
331
+ expect(
332
+ projectDocsIncludeRendererTechniqueDecision(),
333
+ "Custom renderer apps must write a Renderer Technique Decision Matrix in their app spec/plan docs before implementation; AGENTS.md rules alone do not count as the app decision.",
334
+ ).toBe(true);
335
+ });
336
+
337
+ it("requires custom renderer apps to document the renderer layer inventory", () => {
338
+ if (!starterPerformance.usesCustomRenderer) {
339
+ return;
340
+ }
341
+
342
+ expect(
343
+ projectDocsIncludeRendererLayerInventory(),
344
+ "Custom renderer apps must write a Renderer Layer Inventory in their app spec/plan docs so dense raster backgrounds cannot silently rasterize semantic foreground output.",
345
+ ).toBe(true);
346
+ });
347
+
348
+ it("requires custom renderer apps to mirror the renderer decision in typed performance config", () => {
349
+ if (!starterPerformance.usesCustomRenderer) {
350
+ return;
351
+ }
352
+
353
+ expect(
354
+ starterPerformance.rendererTechnique,
355
+ "Custom renderer apps must declare rendererTechnique in app-performance.ts; prose specs and plans are not enough.",
356
+ ).toBeDefined();
357
+ });
358
+
359
+ it("requires custom renderer apps to mirror layer inventory in typed performance config", () => {
360
+ if (!starterPerformance.usesCustomRenderer) {
361
+ return;
362
+ }
363
+
364
+ expect(
365
+ starterPerformance.rendererTechnique?.layers?.length ?? 0,
366
+ "Custom renderer apps must mirror the Renderer Layer Inventory in app-performance.ts rendererTechnique.layers so browser tests and zoom-stress classification can verify the real visual layers.",
367
+ ).toBeGreaterThan(0);
368
+ });
369
+
370
+ it("requires custom renderer apps to explain rejected renderer alternatives", () => {
371
+ if (!starterPerformance.usesCustomRenderer) {
372
+ return;
373
+ }
374
+
375
+ expect(
376
+ projectDocsExplainRendererAlternatives(),
377
+ "Custom renderer specs must explain why the chosen preview/export renderer fits the product better than alternatives, including reference preservation, workload, and product-quality export behavior.",
378
+ ).toBe(true);
379
+ });
380
+
381
+ it("requires renderer-owned hardcoded backgrounds to be schema-controlled or explicitly fixed", () => {
382
+ if (!sourceUsesHardcodedOutputBackgroundColor()) {
383
+ return;
384
+ }
385
+
386
+ expect(
387
+ schemaHasOutputBackgroundColorControl() || projectDocsIncludeFixedBackgroundDecision(),
388
+ "Renderer-owned output background colors must be schema color controls. If a background is intentionally fixed, document the fixed background reason in the app spec/plan so the missing control is deliberate.",
389
+ ).toBe(true);
390
+ });
391
+
392
+ it("requires custom renderers to declare a renderer strategy", () => {
393
+ expect(
394
+ validateToolcraftPerformanceCoverage(starterSchema, {
395
+ rendererStrategy: "none",
396
+ rendererWorkload: "simple-composition",
397
+ scenarios: starterPerformance.scenarios,
398
+ usesCustomRenderer: true,
399
+ workloadTargets: starterPerformance.workloadTargets,
400
+ }),
401
+ ).toEqual(
402
+ expect.arrayContaining([
403
+ 'Custom renderers must declare rendererStrategy "dom", "svg", "canvas-2d", "webgl", or "webgpu".',
404
+ ]),
405
+ );
406
+ });
407
+
408
+ it("rejects renderer strategies on non-custom apps", () => {
409
+ expect(
410
+ validateToolcraftPerformanceCoverage(starterSchema, {
411
+ rendererStrategy: "canvas-2d",
412
+ rendererWorkload: "none",
413
+ scenarios: starterPerformance.scenarios,
414
+ usesCustomRenderer: false,
415
+ workloadTargets: starterPerformance.workloadTargets,
416
+ }),
417
+ ).toEqual(
418
+ expect.arrayContaining([
419
+ 'Non-custom renderer configs must use rendererStrategy "none", received "canvas-2d".',
420
+ ]),
421
+ );
422
+ });
423
+
424
+ it("requires custom renderers to declare a non-empty renderer workload", () => {
425
+ expect(
426
+ validateToolcraftPerformanceCoverage(starterSchema, {
427
+ rendererStrategy: "canvas-2d",
428
+ rendererWorkload: "none",
429
+ scenarios: starterPerformance.scenarios,
430
+ usesCustomRenderer: true,
431
+ workloadTargets: starterPerformance.workloadTargets,
432
+ }),
433
+ ).toEqual(
434
+ expect.arrayContaining([
435
+ 'Custom renderers must declare rendererWorkload "simple-composition", "text-output", "vector-output", or "pixel-output".',
436
+ ]),
437
+ );
438
+ });
439
+
440
+ it("rejects renderer workloads on non-custom apps", () => {
441
+ expect(
442
+ validateToolcraftPerformanceCoverage(starterSchema, {
443
+ rendererStrategy: "none",
444
+ rendererWorkload: "simple-composition",
445
+ scenarios: starterPerformance.scenarios,
446
+ usesCustomRenderer: false,
447
+ workloadTargets: starterPerformance.workloadTargets,
448
+ }),
449
+ ).toEqual(
450
+ expect.arrayContaining([
451
+ 'Non-custom renderer configs must use rendererWorkload "none", received "simple-composition".',
452
+ ]),
453
+ );
454
+ });
455
+
456
+ it("requires pixel-output renderers to use WebGL or WebGPU", () => {
457
+ expect(
458
+ validateToolcraftPerformanceCoverage(starterSchema, {
459
+ rendererStrategy: "canvas-2d",
460
+ rendererWorkload: "pixel-output",
461
+ scenarios: starterPerformance.scenarios,
462
+ usesCustomRenderer: true,
463
+ workloadTargets: starterPerformance.workloadTargets,
464
+ }),
465
+ ).toEqual(
466
+ expect.arrayContaining([
467
+ 'rendererWorkload "pixel-output" must use rendererStrategy "webgl" or "webgpu", received "canvas-2d".',
468
+ ]),
469
+ );
470
+ });
471
+
472
+ it("rejects text and vector product techniques that silently choose pixel output", () => {
473
+ expect(
474
+ validateToolcraftPerformanceCoverage(starterSchema, {
475
+ rendererStrategy: "webgl",
476
+ rendererTechnique: {
477
+ exportRenderer: "webgl",
478
+ fidelityRisks: ["raster output could blur product geometry"],
479
+ performanceRisks: ["large output requires GPU-backed drawing"],
480
+ previewRenderer: "webgl",
481
+ productRepresentation: "vector",
482
+ rendererStrategy: "webgl",
483
+ rendererWorkload: "pixel-output",
484
+ sourceRepresentation: "svg",
485
+ whyNotAlternativeStrategies: ["svg preview was not selected"],
486
+ },
487
+ rendererWorkload: "pixel-output",
488
+ scenarios: starterPerformance.scenarios,
489
+ usesCustomRenderer: true,
490
+ workloadTargets: starterPerformance.workloadTargets,
491
+ }),
492
+ ).toEqual(
493
+ expect.arrayContaining([
494
+ 'productRepresentation "vector" requires rendererWorkload "vector-output" unless intentionalRasterizationReason is provided.',
495
+ ]),
496
+ );
497
+ });
498
+
499
+ it("requires pixel-output renderers to include a stress preview or animation scenario", () => {
500
+ expect(
501
+ validateToolcraftPerformanceCoverage(starterSchema, {
502
+ rendererStrategy: "webgl",
503
+ rendererWorkload: "pixel-output",
504
+ scenarios: [
505
+ {
506
+ automated: true,
507
+ automatedTestName: "perf: preview render stays under budget",
508
+ browser: true,
509
+ browserTestName: "browser perf: preview render stays under budget",
510
+ budget: { maxLongTaskMs: 120, maxPreviewMs: 1000 },
511
+ expectedObservable: "Preview renders without freezing.",
512
+ fixture: "1600x1000 output fixture",
513
+ id: "preview-render",
514
+ interaction: "preview-render",
515
+ workload: false,
516
+ },
517
+ {
518
+ automated: true,
519
+ automatedTestName: "perf: prompt changes stay responsive",
520
+ browser: true,
521
+ browserTestName: "browser perf: prompt changes stay responsive",
522
+ budget: { maxFrameGapMs: 80, maxInteractionMs: 500 },
523
+ controlLabel: "Prompt",
524
+ expectedObservable: "Prompt changes without blocking preview.",
525
+ fixture: "starter prompt fixture",
526
+ id: "generation-prompt-change",
527
+ interaction: "control-change",
528
+ target: "generation.prompt",
529
+ values: {
530
+ default: "Describe the effect",
531
+ max: "Performance verified prompt with a longer generation request",
532
+ min: "",
533
+ },
534
+ workload: true,
535
+ },
536
+ {
537
+ automated: true,
538
+ automatedTestName: "perf: viewport stays stable",
539
+ browser: true,
540
+ browserTestName: "browser perf: viewport stays stable",
541
+ budget: { maxFrameGapMs: 80 },
542
+ expectedObservable: "Viewport remains stable.",
543
+ fixture: "1600x1000 output fixture",
544
+ id: "viewport-stability",
545
+ interaction: "viewport-stability",
546
+ workload: false,
547
+ },
548
+ ],
549
+ usesCustomRenderer: true,
550
+ workloadTargets: ["generation.prompt"],
551
+ }),
552
+ ).toEqual(
553
+ expect.arrayContaining([
554
+ 'rendererWorkload "pixel-output" must include a stress preview-render or animation-frame scenario with stress: true for the largest product canvas and heaviest workload values.',
555
+ ]),
556
+ );
557
+ });
558
+
559
+ it("requires pixel-output renderers to budget long tasks", () => {
560
+ expect(
561
+ validateToolcraftPerformanceCoverage(starterSchema, {
562
+ rendererStrategy: "webgl",
563
+ rendererWorkload: "pixel-output",
564
+ scenarios: [
565
+ {
566
+ automated: true,
567
+ automatedTestName: "perf: preview render stays under budget",
568
+ browser: true,
569
+ browserTestName: "browser perf: preview render stays under budget",
570
+ budget: { maxPreviewMs: 1000 },
571
+ expectedObservable: "Worst-case preview renders without freezing.",
572
+ fixture: "2400x1600 worst-case output fixture",
573
+ id: "preview-render",
574
+ interaction: "preview-render",
575
+ stress: true,
576
+ workload: false,
577
+ },
578
+ {
579
+ automated: true,
580
+ automatedTestName: "perf: prompt changes stay responsive",
581
+ browser: true,
582
+ browserTestName: "browser perf: prompt changes stay responsive",
583
+ budget: { maxFrameGapMs: 80, maxInteractionMs: 500 },
584
+ controlLabel: "Prompt",
585
+ expectedObservable: "Prompt changes without blocking preview.",
586
+ fixture: "starter prompt fixture",
587
+ id: "generation-prompt-change",
588
+ interaction: "control-change",
589
+ target: "generation.prompt",
590
+ values: {
591
+ default: "Describe the effect",
592
+ max: "Performance verified prompt with a longer generation request",
593
+ min: "",
594
+ },
595
+ workload: true,
596
+ },
597
+ {
598
+ automated: true,
599
+ automatedTestName: "perf: viewport stays stable",
600
+ browser: true,
601
+ browserTestName: "browser perf: viewport stays stable",
602
+ budget: { maxFrameGapMs: 80 },
603
+ expectedObservable: "Viewport remains stable.",
604
+ fixture: "1600x1000 output fixture",
605
+ id: "viewport-stability",
606
+ interaction: "viewport-stability",
607
+ workload: false,
608
+ },
609
+ ],
610
+ usesCustomRenderer: true,
611
+ workloadTargets: ["generation.prompt"],
612
+ }),
613
+ ).toEqual(
614
+ expect.arrayContaining([
615
+ 'rendererWorkload "pixel-output" must include at least one maxLongTaskMs budget so GPU-backed previews cannot pass while freezing the main thread.',
616
+ ]),
617
+ );
618
+ });
619
+
620
+ it("detects low-resolution preview upscale code paths", () => {
621
+ expect(
622
+ sourceUsesLowResolutionPreviewUpscale(`
623
+ const maxPreviewPixels = 1_250_000;
624
+ const previewScale = Math.sqrt(maxPreviewPixels / (outputWidth * outputHeight));
625
+ previewContext.drawImage(offscreenCanvas, 0, 0, outputWidth, outputHeight);
626
+ `),
627
+ ).toBe(true);
628
+
629
+ expect(
630
+ sourceUsesLowResolutionPreviewUpscale(`
631
+ canvas.width = outputWidth;
632
+ canvas.height = outputHeight;
633
+ drawAsciiTextToCanvas({ canvas, text });
634
+ `),
635
+ ).toBe(false);
636
+ });
637
+
638
+ it("rejects low-resolution preview upscale for text and vector output renderers", () => {
639
+ if (
640
+ starterPerformance.rendererWorkload !== "text-output" &&
641
+ starterPerformance.rendererWorkload !== "vector-output"
642
+ ) {
643
+ return;
644
+ }
645
+
646
+ expect(
647
+ sourceUsesLowResolutionPreviewUpscale(),
648
+ "Text/vector product previews must preserve native output fidelity. Do not render a low-resolution preview canvas/texture and upscale it to state.canvas.size; optimize layout/drawing instead.",
649
+ ).toBe(false);
650
+ });
651
+
652
+ it("requires text and vector output browser tests to prove native preview resolution", () => {
653
+ if (
654
+ !starterPerformance.usesCustomRenderer ||
655
+ (starterPerformance.rendererWorkload !== "text-output" &&
656
+ starterPerformance.rendererWorkload !== "vector-output")
657
+ ) {
658
+ return;
659
+ }
660
+
661
+ expect(
662
+ browserTestsAssertNativePreviewResolution(),
663
+ "Text/vector custom renderers must have a browser test proving visible preview dimensions match product output dimensions so low-resolution upscale cannot pass unnoticed.",
664
+ ).toBe(true);
665
+ });
666
+
667
+ it("requires procedural pixel-loop renderers to use a GPU strategy", () => {
668
+ if (!sourceUsesCpuPixelLoop()) {
669
+ return;
670
+ }
671
+
672
+ expect(
673
+ starterPerformance.rendererWorkload,
674
+ "Procedural ImageData/getImageData/putImageData renderers must be classified as pixel-output.",
675
+ ).toBe("pixel-output");
676
+ expect(
677
+ starterPerformance.rendererStrategy,
678
+ "Procedural ImageData/getImageData/putImageData renderers must be converted to WebGL/WebGPU or removed from the critical render path.",
679
+ ).toMatch(/^(webgl|webgpu)$/);
680
+ expect(
681
+ sourceUsesGpuRenderer(),
682
+ "Procedural pixel renderers must contain an actual WebGL/WebGPU code path, not only declare a GPU strategy.",
683
+ ).toBe(true);
684
+ });
685
+
686
+ it("requires WebGL/WebGPU renderers to keep their pipeline lifecycle outside React render", () => {
687
+ if (!sourceUsesGpuRenderer()) {
688
+ return;
689
+ }
690
+
691
+ expect(
692
+ sourceUsesWebGlLifecycleGuard(),
693
+ "WebGL/WebGPU renderer setup must be guarded by refs, memoized setup, an effect, or a renderer class so control changes update uniforms/buffers instead of rebuilding the pipeline.",
694
+ ).toBe(true);
695
+ expect(
696
+ sourceCreatesWebGlContextInComponentRender(),
697
+ "Do not create a WebGL context directly in the component render path; initialize it once and update uniforms/buffers on runtime value changes.",
698
+ ).toBe(false);
699
+ });
700
+
701
+ it("requires animation loops to clean up scheduled frames", () => {
702
+ expect(
703
+ sourceUsesAnimationFrameWithoutCleanup(),
704
+ "Renderers that schedule requestAnimationFrame must cancelAnimationFrame on cleanup to avoid runaway loops during control changes or route unmount.",
705
+ ).toBe(false);
706
+ });
707
+
708
+ it("rejects direct app storage writes outside the runtime persistence policy", () => {
709
+ expect(
710
+ sourceUsesDirectStorageApi(),
711
+ "Generated apps must not read or write app state through localStorage/sessionStorage directly. Use runtime persistence policy when product persistence is required.",
712
+ ).toBe(false);
713
+ });
714
+
715
+ it("rejects renderer effects that overwrite user-edited timeline duration", () => {
716
+ expect(
717
+ sourceResyncsTimelineDurationFromRuntimeDuration(),
718
+ "Renderers must not watch state.timeline.durationSeconds and dispatch timeline.setDuration back to a computed local duration. Compute a default only during initialization/reset, then map renderer progress to state.timeline.durationSeconds.",
719
+ ).toBe(false);
720
+ });
721
+
722
+ it("rejects timeline-driven texture uploads in GPU keyframe renderers", () => {
723
+ if (
724
+ !sourceUsesGpuRenderer() ||
725
+ starterSchema.panels.timeline?.enabled !== true ||
726
+ starterSchema.panels.timeline.mode !== "keyframes"
727
+ ) {
728
+ return;
729
+ }
730
+
731
+ expect(
732
+ sourceMayUploadTextureFromTimelineDrivenEffect(),
733
+ "GPU keyframe renderers must upload source textures only when media/resource keys change. Timeline-driven effects may update uniforms and draw, but must not call texImage2D or renderer.setImage from an effect that depends on settings/currentTime/keyframeGroups.",
734
+ ).toBe(false);
735
+ });
736
+
737
+ it("requires workload targets for performance-sensitive schema controls", () => {
738
+ const workloadTargets = new Set(starterPerformance.workloadTargets);
739
+ const sensitiveControls = collectToolcraftPerformanceSensitiveControls(starterSchema);
740
+
741
+ for (const { controlId, target } of sensitiveControls) {
742
+ expect(
743
+ workloadTargets,
744
+ `${controlId} (${target}) looks performance-sensitive and must be listed in workloadTargets or deliberately removed from the app.`,
745
+ ).toContain(target);
746
+ }
747
+ });
748
+
749
+ it("requires each automated performance scenario to point at an app test", () => {
750
+ const testSources = readSiblingAppTestSources();
751
+
752
+ for (const scenario of starterPerformance.scenarios) {
753
+ if (!scenario.automated) {
754
+ continue;
755
+ }
756
+
757
+ expect(
758
+ testSources,
759
+ `${scenario.id} must be backed by an app test named "${scenario.automatedTestName}".`,
760
+ ).toContain(scenario.automatedTestName);
761
+ }
762
+ });
763
+
764
+ it("requires each browser performance scenario to point at a Playwright test", () => {
765
+ const browserTestSources = readBrowserTestSources();
766
+
767
+ for (const scenario of starterPerformance.scenarios) {
768
+ if (!scenario.browser) {
769
+ continue;
770
+ }
771
+
772
+ expect(
773
+ browserTestSources,
774
+ `${scenario.id} must be backed by a Playwright test named "${scenario.browserTestName}".`,
775
+ ).toContain(scenario.browserTestName);
776
+ }
777
+ });
778
+
779
+ it("rejects reused browser performance tests across scenarios", () => {
780
+ expect(
781
+ validateToolcraftPerformanceCoverage(starterSchema, {
782
+ rendererStrategy: "none",
783
+ rendererWorkload: "none",
784
+ scenarios: [
785
+ {
786
+ automated: true,
787
+ automatedTestName: "perf: prompt min change stays responsive",
788
+ browser: true,
789
+ browserTestName: "browser perf: prompt changes stay responsive",
790
+ budget: { maxFrameGapMs: 80, maxInteractionMs: 500 },
791
+ controlLabel: "Prompt",
792
+ expectedObservable: "Prompt min edit updates product output.",
793
+ fixture: "starter prompt fixture",
794
+ id: "prompt-min-change",
795
+ interaction: "control-change",
796
+ target: "generation.prompt",
797
+ values: { default: "Describe the effect", max: "Long prompt", min: "" },
798
+ workload: true,
799
+ },
800
+ {
801
+ automated: true,
802
+ automatedTestName: "perf: prompt max change stays responsive",
803
+ browser: true,
804
+ browserTestName: "browser perf: prompt changes stay responsive",
805
+ budget: { maxFrameGapMs: 80, maxInteractionMs: 500 },
806
+ controlLabel: "Prompt",
807
+ expectedObservable: "Prompt max edit updates product output.",
808
+ fixture: "starter prompt fixture",
809
+ id: "prompt-max-change",
810
+ interaction: "control-change",
811
+ target: "generation.prompt",
812
+ values: { default: "Describe the effect", max: "Long prompt", min: "" },
813
+ workload: true,
814
+ },
815
+ ],
816
+ usesCustomRenderer: false,
817
+ workloadTargets: ["generation.prompt"],
818
+ }),
819
+ ).toEqual(
820
+ expect.arrayContaining([
821
+ 'prompt-max-change browserTestName "browser perf: prompt changes stay responsive" is already used by prompt-min-change. Give each performance scenario its own browser test so every control is actually exercised.',
822
+ ]),
823
+ );
824
+ });
825
+
826
+ it("fails custom renderer configs that omit real workload coverage", () => {
827
+ expect(
828
+ validateToolcraftPerformanceCoverage(starterSchema, {
829
+ rendererStrategy: "canvas-2d",
830
+ rendererWorkload: "simple-composition",
831
+ scenarios: [
832
+ {
833
+ automated: true,
834
+ automatedTestName: "perf: preview render stays under budget",
835
+ browser: true,
836
+ browserTestName: "browser perf: preview render stays interactive",
837
+ budget: { maxPreviewMs: 100 },
838
+ expectedObservable: "Preview renders without freezing.",
839
+ fixture: "1600x1000 gradient fixture",
840
+ id: "preview-render",
841
+ interaction: "preview-render",
842
+ workload: false,
843
+ },
844
+ ],
845
+ usesCustomRenderer: true,
846
+ workloadTargets: ["generation.prompt"],
847
+ }),
848
+ ).toEqual(
849
+ expect.arrayContaining([
850
+ "Custom renderers must include a control-drag performance scenario.",
851
+ "generation.prompt must have min/default/max workload performance coverage.",
852
+ ]),
853
+ );
854
+ });
855
+
856
+ it("requires custom renderers to cover upload, output actions, and viewport stability", () => {
857
+ const outputSchema = defineToolcraft({
858
+ canvas: {
859
+ enabled: true,
860
+ size: { height: 1080, unit: "px", width: 1920 },
861
+ sizing: { mode: "editable-output" },
862
+ upload: true,
863
+ },
864
+ panels: {
865
+ controls: {
866
+ sections: [
867
+ {
868
+ controls: {
869
+ export: {
870
+ actions: [{ label: "Export PNG", value: "export-png" }],
871
+ target: "panel.actions",
872
+ type: "panelActions",
873
+ },
874
+ quality: {
875
+ defaultValue: 0.5,
876
+ label: "Quality",
877
+ max: 1,
878
+ min: 0,
879
+ target: "render.quality",
880
+ type: "slider",
881
+ },
882
+ },
883
+ },
884
+ ],
885
+ title: "Renderer Controls",
886
+ },
887
+ },
888
+ });
889
+
890
+ expect(
891
+ validateToolcraftPerformanceCoverage(outputSchema, {
892
+ rendererStrategy: "canvas-2d",
893
+ rendererWorkload: "simple-composition",
894
+ scenarios: [
895
+ {
896
+ automated: true,
897
+ automatedTestName: "perf: preview render stays under budget",
898
+ browser: true,
899
+ browserTestName: "browser perf: preview render stays under budget",
900
+ budget: { maxPreviewMs: 1000 },
901
+ expectedObservable: "Preview renders without freezing.",
902
+ fixture: "1920x1080 output fixture",
903
+ id: "preview-render",
904
+ interaction: "preview-render",
905
+ workload: false,
906
+ },
907
+ {
908
+ automated: true,
909
+ automatedTestName: "perf: quality drag stays responsive",
910
+ browser: true,
911
+ browserTestName: "browser perf: quality drag stays responsive",
912
+ budget: { maxFrameGapMs: 50, maxInteractionMs: 250 },
913
+ controlLabel: "Quality",
914
+ expectedObservable: "Dragging Quality remains responsive.",
915
+ fixture: "1920x1080 output fixture",
916
+ id: "quality-drag",
917
+ interaction: "control-drag",
918
+ target: "render.quality",
919
+ values: { default: 0.5, max: 1, min: 0 },
920
+ workload: true,
921
+ },
922
+ ],
923
+ usesCustomRenderer: true,
924
+ workloadTargets: ["render.quality"],
925
+ }),
926
+ ).toEqual(
927
+ expect.arrayContaining([
928
+ "Custom renderers must include a viewport-stability performance scenario.",
929
+ "Custom renderers with canvas upload must include a media-import performance scenario.",
930
+ "Output actions must include an export-copy performance scenario.",
931
+ ]),
932
+ );
933
+ });
934
+
935
+ it("requires keyframe custom renderers to cover viewport stability during keyframe interactions", () => {
936
+ const keyframeSchema = defineToolcraft({
937
+ canvas: {
938
+ enabled: true,
939
+ sizing: { mode: "intrinsic-media" },
940
+ },
941
+ panels: {
942
+ controls: {
943
+ sections: [
944
+ {
945
+ controls: {
946
+ intensity: {
947
+ defaultValue: 0.5,
948
+ label: "Intensity",
949
+ max: 1,
950
+ min: 0,
951
+ target: "render.intensity",
952
+ type: "slider",
953
+ },
954
+ },
955
+ title: "Render",
956
+ },
957
+ ],
958
+ title: "Render Controls",
959
+ },
960
+ timeline: { mode: "keyframes" },
961
+ },
962
+ });
963
+
964
+ expect(
965
+ validateToolcraftPerformanceCoverage(keyframeSchema, {
966
+ rendererStrategy: "webgl",
967
+ rendererWorkload: "simple-composition",
968
+ scenarios: [
969
+ {
970
+ automated: true,
971
+ automatedTestName: "perf: preview render stays under budget",
972
+ browser: true,
973
+ browserTestName: "browser perf: preview render stays under budget",
974
+ budget: { maxPreviewMs: 1000 },
975
+ expectedObservable: "Preview renders without freezing.",
976
+ fixture: "1440x1080 shader fixture",
977
+ id: "preview-render",
978
+ interaction: "preview-render",
979
+ workload: false,
980
+ },
981
+ {
982
+ automated: true,
983
+ automatedTestName: "perf: intensity drag stays responsive",
984
+ browser: true,
985
+ browserTestName: "browser perf: intensity drag stays responsive",
986
+ budget: { maxFrameGapMs: 80, maxInteractionMs: 500 },
987
+ controlLabel: "Intensity",
988
+ expectedObservable: "Dragging Intensity remains responsive.",
989
+ fixture: "1440x1080 shader fixture",
990
+ id: "intensity-drag",
991
+ interaction: "control-drag",
992
+ target: "render.intensity",
993
+ values: { default: 0.5, max: 1, min: 0 },
994
+ workload: true,
995
+ },
996
+ {
997
+ automated: true,
998
+ automatedTestName: "perf: viewport stays stable",
999
+ browser: true,
1000
+ browserTestName: "browser perf: viewport stays stable",
1001
+ budget: { maxFrameGapMs: 80 },
1002
+ expectedObservable: "Changing controls does not move the canvas viewport.",
1003
+ fixture: "1440x1080 shader fixture",
1004
+ id: "viewport-stability",
1005
+ interaction: "viewport-stability",
1006
+ target: "render.intensity",
1007
+ workload: false,
1008
+ },
1009
+ ],
1010
+ usesCustomRenderer: true,
1011
+ workloadTargets: ["render.intensity"],
1012
+ }),
1013
+ ).toEqual(
1014
+ expect.arrayContaining([
1015
+ 'Keyframe custom renderers must include a viewport-stability performance scenario with target "timeline.keyframes" that exercises zoom/radar, expanded keyframes, keyframe creation, and playback or scrubbing.',
1016
+ ]),
1017
+ );
1018
+ });
1019
+
1020
+ it("requires layer custom renderers to cover viewport stability during layer interactions", () => {
1021
+ const layerSchema = defineToolcraft({
1022
+ canvas: {
1023
+ enabled: true,
1024
+ upload: true,
1025
+ },
1026
+ panels: {
1027
+ controls: {
1028
+ sections: [
1029
+ {
1030
+ controls: {
1031
+ opacity: {
1032
+ defaultValue: 1,
1033
+ label: "Opacity",
1034
+ max: 1,
1035
+ min: 0,
1036
+ target: "selectedLayer.opacity",
1037
+ type: "slider",
1038
+ },
1039
+ },
1040
+ title: "Layer",
1041
+ },
1042
+ ],
1043
+ title: "Layer Controls",
1044
+ },
1045
+ layers: true,
1046
+ },
1047
+ });
1048
+
1049
+ expect(
1050
+ validateToolcraftPerformanceCoverage(layerSchema, {
1051
+ rendererStrategy: "webgl",
1052
+ rendererWorkload: "simple-composition",
1053
+ scenarios: [
1054
+ {
1055
+ automated: true,
1056
+ automatedTestName: "perf: preview render stays under budget",
1057
+ browser: true,
1058
+ browserTestName: "browser perf: preview render stays under budget",
1059
+ budget: { maxPreviewMs: 1000 },
1060
+ expectedObservable: "Preview renders without freezing.",
1061
+ fixture: "two-layer image fixture",
1062
+ id: "preview-render",
1063
+ interaction: "preview-render",
1064
+ workload: false,
1065
+ },
1066
+ {
1067
+ automated: true,
1068
+ automatedTestName: "perf: opacity drag stays responsive",
1069
+ browser: true,
1070
+ browserTestName: "browser perf: opacity drag stays responsive",
1071
+ budget: { maxFrameGapMs: 80, maxInteractionMs: 500 },
1072
+ controlLabel: "Opacity",
1073
+ expectedObservable: "Dragging selected layer opacity remains responsive.",
1074
+ fixture: "two-layer image fixture",
1075
+ id: "opacity-drag",
1076
+ interaction: "control-drag",
1077
+ target: "selectedLayer.opacity",
1078
+ values: { default: 1, max: 1, min: 0 },
1079
+ workload: true,
1080
+ },
1081
+ {
1082
+ automated: true,
1083
+ automatedTestName: "perf: viewport stays stable",
1084
+ browser: true,
1085
+ browserTestName: "browser perf: viewport stays stable",
1086
+ budget: { maxFrameGapMs: 80 },
1087
+ expectedObservable: "Changing controls does not move the canvas viewport.",
1088
+ fixture: "two-layer image fixture",
1089
+ id: "viewport-stability",
1090
+ interaction: "viewport-stability",
1091
+ target: "selectedLayer.opacity",
1092
+ workload: false,
1093
+ },
1094
+ ],
1095
+ usesCustomRenderer: true,
1096
+ workloadTargets: ["selectedLayer.opacity"],
1097
+ }),
1098
+ ).toEqual(
1099
+ expect.arrayContaining([
1100
+ 'Layer-enabled custom renderers must include a viewport-stability performance scenario with target "layers.interactions" that exercises zoom/radar, layer selection, visibility, reorder or grouping, and selected-layer output stability.',
1101
+ ]),
1102
+ );
1103
+ });
1104
+
1105
+ it("requires every visible non-action control to have performance coverage", () => {
1106
+ const rendererSchema = defineToolcraft({
1107
+ canvas: {
1108
+ enabled: true,
1109
+ size: { height: 1080, unit: "px", width: 1440 },
1110
+ sizing: { mode: "editable-output" },
1111
+ },
1112
+ panels: {
1113
+ controls: {
1114
+ sections: [
1115
+ {
1116
+ controls: {
1117
+ depth: {
1118
+ defaultValue: 50,
1119
+ label: "Depth",
1120
+ max: 100,
1121
+ min: 0,
1122
+ target: "shader.depth",
1123
+ type: "slider",
1124
+ },
1125
+ gradient: {
1126
+ defaultValue: "aurora",
1127
+ label: "Gradient",
1128
+ options: [
1129
+ { label: "Aurora", value: "aurora" },
1130
+ { label: "Prism", value: "prism" },
1131
+ ],
1132
+ target: "shader.gradient",
1133
+ type: "select",
1134
+ },
1135
+ mode: {
1136
+ defaultValue: "soft",
1137
+ label: "Mode",
1138
+ options: [
1139
+ { label: "Soft", value: "soft" },
1140
+ { label: "Sharp", value: "sharp" },
1141
+ ],
1142
+ target: "shader.mode",
1143
+ type: "segmented",
1144
+ },
1145
+ },
1146
+ title: "Shader",
1147
+ },
1148
+ ],
1149
+ title: "Shader Controls",
1150
+ },
1151
+ },
1152
+ });
1153
+
1154
+ expect(
1155
+ validateToolcraftPerformanceCoverage(rendererSchema, {
1156
+ rendererStrategy: "canvas-2d",
1157
+ rendererWorkload: "simple-composition",
1158
+ scenarios: [
1159
+ {
1160
+ automated: true,
1161
+ automatedTestName: "perf: preview render stays under budget",
1162
+ browser: true,
1163
+ browserTestName: "browser perf: preview render stays under budget",
1164
+ budget: { maxPreviewMs: 1000 },
1165
+ expectedObservable: "Preview renders without freezing.",
1166
+ fixture: "1440x1080 shader fixture",
1167
+ id: "preview-render",
1168
+ interaction: "preview-render",
1169
+ workload: false,
1170
+ },
1171
+ {
1172
+ automated: true,
1173
+ automatedTestName: "perf: depth drag stays responsive",
1174
+ browser: true,
1175
+ browserTestName: "browser perf: depth drag stays responsive",
1176
+ budget: { maxFrameGapMs: 50, maxInteractionMs: 250 },
1177
+ controlLabel: "Depth",
1178
+ expectedObservable: "Dragging Depth remains responsive.",
1179
+ fixture: "1440x1080 shader fixture",
1180
+ id: "depth-drag",
1181
+ interaction: "control-drag",
1182
+ target: "shader.depth",
1183
+ values: { default: 50, max: 100, min: 0 },
1184
+ workload: true,
1185
+ },
1186
+ {
1187
+ automated: true,
1188
+ automatedTestName: "perf: viewport stays stable",
1189
+ browser: true,
1190
+ browserTestName: "browser perf: viewport stays stable",
1191
+ budget: { maxFrameGapMs: 50 },
1192
+ expectedObservable: "Canvas zoom and offset do not jump.",
1193
+ fixture: "1440x1080 shader fixture",
1194
+ id: "viewport-stability",
1195
+ interaction: "viewport-stability",
1196
+ workload: false,
1197
+ },
1198
+ ],
1199
+ usesCustomRenderer: true,
1200
+ workloadTargets: ["shader.depth"],
1201
+ }),
1202
+ ).toEqual(
1203
+ expect.arrayContaining([
1204
+ "canvas.size.width must have a performance scenario because every visible control can affect app responsiveness.",
1205
+ "canvas.size.height must have a performance scenario because every visible control can affect app responsiveness.",
1206
+ "shader.gradient must have a performance scenario because every visible control can affect app responsiveness.",
1207
+ "shader.mode must have a performance scenario because every visible control can affect app responsiveness.",
1208
+ ]),
1209
+ );
1210
+ });
1211
+
1212
+ it("requires visible control performance coverage even without a custom renderer", () => {
1213
+ const controlsSchema = defineToolcraft({
1214
+ canvas: {
1215
+ enabled: true,
1216
+ sizing: { mode: "intrinsic-media" },
1217
+ },
1218
+ panels: {
1219
+ controls: {
1220
+ sections: [
1221
+ {
1222
+ controls: {
1223
+ mode: {
1224
+ defaultValue: "soft",
1225
+ label: "Mode",
1226
+ options: [
1227
+ { label: "Soft", value: "soft" },
1228
+ { label: "Sharp", value: "sharp" },
1229
+ ],
1230
+ target: "app.mode",
1231
+ type: "select",
1232
+ },
1233
+ },
1234
+ title: "Display",
1235
+ },
1236
+ ],
1237
+ title: "Controls",
1238
+ },
1239
+ },
1240
+ });
1241
+
1242
+ expect(
1243
+ validateToolcraftPerformanceCoverage(controlsSchema, {
1244
+ rendererStrategy: "none",
1245
+ rendererWorkload: "none",
1246
+ scenarios: [],
1247
+ usesCustomRenderer: false,
1248
+ workloadTargets: [],
1249
+ }),
1250
+ ).toEqual(
1251
+ expect.arrayContaining([
1252
+ "app.mode must have a performance scenario because every visible control can affect app responsiveness.",
1253
+ ]),
1254
+ );
1255
+ });
1256
+
1257
+ it("requires budgets that match each performance interaction type", () => {
1258
+ expect(
1259
+ validateToolcraftPerformanceCoverage(starterSchema, {
1260
+ rendererStrategy: "none",
1261
+ rendererWorkload: "none",
1262
+ scenarios: [
1263
+ {
1264
+ automated: true,
1265
+ automatedTestName: "perf: char size drag stays responsive",
1266
+ browser: true,
1267
+ browserTestName: "browser perf: char size drag stays responsive",
1268
+ budget: { maxExportMs: 100 },
1269
+ expectedObservable: "Dragging Char Size remains responsive.",
1270
+ fixture: "1600x1000 transparent glyph fixture",
1271
+ id: "char-size-drag",
1272
+ interaction: "control-drag",
1273
+ target: "generation.prompt",
1274
+ values: { default: 12, max: 32, min: 4 },
1275
+ workload: true,
1276
+ },
1277
+ {
1278
+ automated: true,
1279
+ automatedTestName: "perf: export png stays under budget",
1280
+ browser: true,
1281
+ browserTestName: "browser perf: export png stays under budget",
1282
+ budget: { maxFrameGapMs: 50 },
1283
+ expectedObservable: "Export completes without blocking the UI.",
1284
+ fixture: "1920x1080 output fixture",
1285
+ id: "export-png",
1286
+ interaction: "export-copy",
1287
+ workload: false,
1288
+ },
1289
+ {
1290
+ automated: true,
1291
+ automatedTestName: "perf: preview render stays under budget",
1292
+ browser: true,
1293
+ browserTestName: "browser perf: preview render stays under budget",
1294
+ budget: { maxFrameGapMs: 50 },
1295
+ expectedObservable: "Preview renders without freezing.",
1296
+ fixture: "1920x1080 output fixture",
1297
+ id: "preview-render",
1298
+ interaction: "preview-render",
1299
+ workload: false,
1300
+ },
1301
+ {
1302
+ automated: true,
1303
+ automatedTestName: "perf: animation frame loop stays smooth",
1304
+ browser: true,
1305
+ browserTestName: "browser perf: animation frame loop stays smooth",
1306
+ budget: { maxFrameGapMs: 50 },
1307
+ expectedObservable: "Animation advances without frame stalls.",
1308
+ fixture: "animated output fixture",
1309
+ id: "animation-frame-loop",
1310
+ interaction: "animation-frame",
1311
+ workload: false,
1312
+ },
1313
+ ],
1314
+ usesCustomRenderer: false,
1315
+ workloadTargets: ["generation.prompt"],
1316
+ }),
1317
+ ).toEqual(
1318
+ expect.arrayContaining([
1319
+ "char-size-drag control-drag scenario must declare maxInteractionMs and maxFrameGapMs.",
1320
+ "export-png export-copy scenario must declare maxExportMs.",
1321
+ "preview-render preview-render scenario must declare maxPreviewMs or maxRenderMs.",
1322
+ "animation-frame-loop animation-frame scenario must declare maxLongTaskMs.",
1323
+ ]),
1324
+ );
1325
+ });
1326
+
1327
+ it("rejects performance budgets that are too loose to catch lag", () => {
1328
+ expect(
1329
+ validateToolcraftPerformanceCoverage(starterSchema, {
1330
+ rendererStrategy: "none",
1331
+ rendererWorkload: "none",
1332
+ scenarios: [
1333
+ {
1334
+ automated: true,
1335
+ automatedTestName: "perf: char size drag stays responsive",
1336
+ browser: true,
1337
+ browserTestName: "browser perf: char size drag stays responsive",
1338
+ budget: { maxFrameGapMs: 1000, maxInteractionMs: 10000 },
1339
+ controlLabel: "Char Size",
1340
+ expectedObservable: "Dragging Char Size remains responsive.",
1341
+ fixture: "1600x1000 transparent glyph fixture",
1342
+ id: "char-size-drag",
1343
+ interaction: "control-drag",
1344
+ target: "generation.prompt",
1345
+ values: { default: 12, max: 32, min: 4 },
1346
+ workload: true,
1347
+ },
1348
+ ],
1349
+ usesCustomRenderer: false,
1350
+ workloadTargets: ["generation.prompt"],
1351
+ }),
1352
+ ).toEqual(
1353
+ expect.arrayContaining([
1354
+ "char-size-drag maxFrameGapMs budget must be <= 120ms, received 1000ms.",
1355
+ "char-size-drag maxInteractionMs budget must be <= 2000ms, received 10000ms.",
1356
+ ]),
1357
+ );
1358
+ });
1359
+
1360
+ it("requires real browser interaction metadata for control performance scenarios", () => {
1361
+ expect(
1362
+ validateToolcraftPerformanceCoverage(starterSchema, {
1363
+ rendererStrategy: "none",
1364
+ rendererWorkload: "none",
1365
+ scenarios: [
1366
+ {
1367
+ automated: true,
1368
+ automatedTestName: "perf: char size drag stays responsive",
1369
+ browser: true,
1370
+ browserTestName: "browser perf: char size drag stays responsive",
1371
+ budget: { maxFrameGapMs: 50, maxInteractionMs: 250 },
1372
+ expectedObservable: "Dragging Char Size remains responsive.",
1373
+ fixture: "1600x1000 transparent glyph fixture",
1374
+ id: "char-size-drag",
1375
+ interaction: "control-drag",
1376
+ target: "generation.prompt",
1377
+ values: { default: 12, max: 32, min: 4 },
1378
+ workload: true,
1379
+ },
1380
+ ],
1381
+ usesCustomRenderer: false,
1382
+ workloadTargets: ["generation.prompt"],
1383
+ }),
1384
+ ).toEqual(
1385
+ expect.arrayContaining([
1386
+ "char-size-drag control-drag scenario must declare controlLabel or uiSelector for its real browser interaction.",
1387
+ ]),
1388
+ );
1389
+ });
1390
+ });