@pixel-point/toolcraft 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/generate.test.mjs +1 -1
- package/templates/runtime/contracts/component-contracts.test.ts +123 -15
- package/templates/runtime/contracts/component-contracts.ts +93 -19
- package/templates/runtime/contracts/decision-contracts.test.ts +3 -2
- package/templates/runtime/contracts/decision-contracts.ts +2 -2
- package/templates/runtime/export/export.test.ts +31 -0
- package/templates/runtime/export/export.ts +41 -0
- package/templates/runtime/react/canvas-shell.test.tsx +77 -1
- package/templates/runtime/react/canvas-shell.tsx +178 -23
- package/templates/runtime/react/control-conditions.ts +166 -0
- package/templates/runtime/react/controls-panel-filedrop-reorder.test.tsx +176 -0
- package/templates/runtime/react/controls-panel.test.tsx +590 -9
- package/templates/runtime/react/controls-panel.tsx +472 -10
- package/templates/runtime/react/media-file.ts +19 -0
- package/templates/runtime/schema/define-toolcraft.test.ts +2 -0
- package/templates/runtime/schema/define-toolcraft.ts +11 -3
- package/templates/runtime/schema/types.ts +27 -0
- package/templates/runtime/state/reducer.test.ts +357 -0
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/types.ts +12 -2
- package/templates/runtime/testing/performance.test.ts +1282 -48
- package/templates/runtime/testing/performance.ts +676 -39
- package/templates/starter/AGENTS.md +13 -12
- package/templates/starter/docs/toolcraft/README.md +4 -3
- package/templates/starter/docs/toolcraft/acceptance-testing.md +17 -7
- package/templates/starter/docs/toolcraft/assembly-workflow.md +9 -7
- package/templates/starter/docs/toolcraft/component-rules.md +20 -7
- package/templates/starter/docs/toolcraft/custom-controls.md +9 -5
- package/templates/starter/docs/toolcraft/performance.md +51 -13
- package/templates/starter/docs/toolcraft/renderer-technique.md +4 -0
- package/templates/starter/docs/toolcraft/schema-reference.md +11 -6
- package/templates/starter/docs/toolcraft/workflow.md +7 -10
- package/templates/starter/e2e/app-performance.spec.ts +136 -3
- package/templates/starter/e2e/performance-helpers.ts +197 -0
- package/templates/starter/package.json +4 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +529 -19
- package/templates/starter/src/app/starter-acceptance.ts +269 -12
- package/templates/starter/src/app/starter-performance.test.ts +67 -6
- package/templates/ui/components/controls/actions/actions-control.tsx +3 -5
- package/templates/ui/components/controls/collection-actions/collection-actions-control.tsx +60 -0
- package/templates/ui/components/controls/collection-actions/index.ts +4 -0
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +340 -44
- package/templates/ui/components/controls/file-drop/index.ts +1 -1
- package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -6
- package/templates/ui/components/controls/index.ts +9 -0
- package/templates/ui/components/panel/panel-section.tsx +53 -1
- package/templates/ui/index.ts +1 -0
|
@@ -45,7 +45,7 @@ export: {
|
|
|
45
45
|
- `appearance.background` or `scene.background` as a `color` control;
|
|
46
46
|
- `export.includeBackground` as a boolean/options control.
|
|
47
47
|
|
|
48
|
-
PNG exporters should call `createToolcraftPngExportCanvas({ background, includeBackground, resolution, state, render })`, where `background`, `includeBackground`, and `resolution` come from runtime state. `
|
|
48
|
+
PNG exporters should call `createToolcraftPngExportCanvas({ background, includeBackground, resolution, state, render })`, where `background`, `includeBackground`, and `resolution` come from runtime state. Live preview renderers should call `shouldIncludeToolcraftPreviewBackground(state)` and hide only the product-rendered background when it returns false; do not hide or replace the Toolcraft canvas shell/backing. For every app with `Export PNG`, `resolution` comes from `export.image.resolution`: `2k`, `4k`, and `8k` render actual 2048/4096/8192px long-edge PNGs. `current` or omitted resolution falls back to retina sizing. Video export always includes the product background, uses `getToolcraftRetinaExportSize`, and must prove exported metadata duration matches the runtime timeline duration.
|
|
49
49
|
|
|
50
50
|
Every app with `Export PNG` exposes a separate `Image Export` controls section. For still-output apps it sits directly above sticky footer actions. For animated apps with both `Export PNG` and `Export Video`, it sits immediately before `Video Export`:
|
|
51
51
|
|
|
@@ -140,7 +140,7 @@ Resolved `canvas.size` exists for every canvas app, but visible `Aspect ratio`,
|
|
|
140
140
|
|
|
141
141
|
When the user manually edits `Canvas width` or `Canvas height`, the runtime treats that as an exact custom output size. It keeps the typed dimension, keeps the other dimension unchanged, switches `Aspect ratio` to `Custom`, and shows the reduced current ratio in the custom ratio inputs. Only selecting an aspect preset may resize both dimensions from a canonical preset.
|
|
142
142
|
|
|
143
|
-
For non-vector raster, Canvas 2D, WebGL, or WebGPU previews, set `canvas.renderScale: true`. Runtime appends `Resolution scale` after canvas sizing in the first technical `Setup` section. The slider ranges from `1x` to `2x`, defaults to `2x`, and changes the renderer backing pixels without changing the visible CSS size or product output dimensions. Adding or enabling this slider requires
|
|
143
|
+
For non-vector raster, Canvas 2D, WebGL, or WebGPU previews, set `canvas.renderScale: true`. Runtime appends `Resolution scale` after canvas sizing in the first technical `Setup` section. The slider ranges from `1x` to `2x`, defaults to `2x`, and changes the renderer backing pixels without changing the visible CSS size or product output dimensions. Adding or enabling this slider requires targeted browser evidence that the canvas stays responsive while dragging sliders or other high-frequency controls at the selected scale. Full `pnpm verify:perf` is required only for the first working app version or explicit performance complaints. Performance fixes must preserve the selected scale. Diagnose whether lag comes from renderer technique, React update frequency, decoded media, shader/program setup, buffer uploads, layout work, stale async renders, or animation scheduling before reducing quality. Do not silently downsample, stretch a lower-resolution backing canvas, blur output, or clamp `canvas.renderScale` below the user's chosen value. Do not enable it for DOM/SVG/vector-native previews; use native vector rendering for those.
|
|
144
144
|
|
|
145
145
|
## Panels
|
|
146
146
|
|
|
@@ -161,12 +161,13 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
|
|
|
161
161
|
| `anchorGrid` | Anchor picker | `defaultValue`, `target` |
|
|
162
162
|
| `channelMixer` | RGB-only channel matrix mixer with R/G/B tabs and Red/Green/Blue source sliders | `defaultValue`, `target`, `label` |
|
|
163
163
|
| `checkbox` | Checkbox field | `defaultValue`, `target`, `label` |
|
|
164
|
+
| `collectionActions` | Add/remove buttons for repeatable product entities and their runtime-backed item controls | `defaultValue: []`, `itemControl`, `itemDefaultValue`, `itemLabel`, `minItems`, `recommendedMaxItems`, `hardMaxItems`, `target` |
|
|
164
165
|
| `code` | Multiline textarea | `defaultValue`, `target`, `label` |
|
|
165
166
|
| `color` | Hex color picker | `defaultValue: { hex }`, `target`, `label` |
|
|
166
167
|
| `colorOpacity` | Hex color picker plus opacity percent input | `defaultValue: { hex, opacity }`, `target`, `label` |
|
|
167
168
|
| `curves` | RGB or single curve editor | `defaultValue`, `target`, `variant: "single"`, `interpolation: "smooth" \| "monotone"` |
|
|
168
|
-
| `fileDrop` | Upload/drop input | `accept`, `multiple`, `target` |
|
|
169
|
-
| `fontPicker` | Font preview select with popup, category search, weight, size, text case, text color/opacity, letter spacing, and line height; product text must consume `fontId`, `fontWeight`, `fontSize`, `letterSpacing`, `lineHeight`, `textCase`, `color`, and `opacity` | `defaultValue: { fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, opacity }`, `target` |
|
|
169
|
+
| `fileDrop` | Upload/drop input; `assetKind: "image"` owns image previews and `assetKind: "file"` owns sortable arbitrary file lists | `assetKind`, `accept`, `multiple`, `defaultValue`, `target` |
|
|
170
|
+
| `fontPicker` | Font preview select with popup, category search, weight, size, text case, text color/opacity, letter spacing, and line height; product text must consume `fontId`, `fontWeight`, `fontSize`, `letterSpacing`, `lineHeight`, `textCase`, `color`, and `opacity`; default text color is `#FFFFFF` at `100` opacity | `defaultValue: { fontId, fontWeight, fontSize, letterSpacing, lineHeight, textCase, color, opacity }`, `target` |
|
|
170
171
|
| `gradient` | Gradient editor | `defaultValue: { angle, gradientType, stops }`, `target` |
|
|
171
172
|
| `imagePicker` | Image choice grid | `items`, `defaultValue`, `target` |
|
|
172
173
|
| `palette` | Constrained design-token palette picker for family + shade | `defaultValue: { family, shade }`, `target` |
|
|
@@ -184,6 +185,8 @@ Use built-ins before custom controls. Unknown `type` values render nothing unles
|
|
|
184
185
|
|
|
185
186
|
For `slider` and `rangeSlider`, `unit` is a measurement or scale suffix, not the entity being counted. Use units such as `%`, `px`, `°`, `x`, `s`, `ms`, `fps`, `rows`, or `cols` only when they clarify the number. Do not add repeated nouns such as `letters`, `shapes`, `words`, `symbols`, `items`, `particles`, or `layers` when the label or section already names that entity. If the value needs a noun, rename the label or section. Word or acronym units, when truly needed, render with a space (`5 cols`, `17 fps`); compact symbol/CSS units stay tight (`70%`, `24px`).
|
|
186
187
|
|
|
188
|
+
Use `collectionActions` when the product owns a growable/shrinkable item list. `minItems` protects the smallest valid output, `recommendedMaxItems` is only a design recommendation, and `hardMaxItems` is valid only for a real product or technical limit. Adding/removing items must update the runtime array and the renderer/export must consume that same array. Do not pair a count slider with hidden fixed item controls when the user needs to add or remove actual entities. The collection label is on the left and remove/add buttons stay on the right. Homogeneous repeated items do not show visible per-item labels when the collection label already names the group. `itemControl.type` supports normal item built-ins such as `color`, `colorOpacity`, `text`, `select`, `segmented`, `slider`, `switch`, `checkbox`, `rangeInput`, and `fontPicker`; item controls still follow normal density rules, so plain colors use equal 50% columns when they fit. Use `fontPicker` as the item control when each repeated item is a text style or typography entity; do not split its font, weight, size, case, color/opacity, letter spacing, or line height into sibling collection fields.
|
|
189
|
+
|
|
187
190
|
## Control Selection Inventory
|
|
188
191
|
|
|
189
192
|
Before writing schema controls, map product needs to built-ins by value model, not visual similarity.
|
|
@@ -235,6 +238,8 @@ Section expand/collapse uses the standard runtime height/opacity animation. Do n
|
|
|
235
238
|
|
|
236
239
|
Ordinary section collapsed/expanded state persists as a per-app runtime UI preference. It is not undo/redo state, not settings import/export state, and `Reset controls` must not clear it. Runtime technical `Setup` / settings sections and sticky footer `Export` sections are not collapsible.
|
|
237
240
|
|
|
241
|
+
Ordinary section headers expose the runtime section reset action before the collapse button. It dispatches `controls.resetTargets` and restores only that section's control targets to their schema `defaultValue`.
|
|
242
|
+
|
|
238
243
|
Ordinary controls-panel body sections use 8px top spacing and 24px bottom spacing for their control content. Runtime technical `Setup` / settings sections use 12px top and bottom spacing to match side padding. Sticky footer action sections keep their dedicated spacing.
|
|
239
244
|
|
|
240
245
|
Large built-in compound controls inside mixed sections render content-width internal dividers with 18px between each rendered divider and the control content. If a compound control is the first item in that section, render only its bottom internal divider and remove the top internal padding. If a section contains exactly one control, whether simple or compound, only the parent section dividers render. Single `curves` are not compound for dividers; RGB `curves` are compound.
|
|
@@ -249,7 +254,7 @@ If a target prefix has to be split across sections, the spec must name the workf
|
|
|
249
254
|
|
|
250
255
|
Switch and checkbox labels name the setting context only. Do not prefix them with `Enable` or `Disable`; use `CRT`, `Glow`, `Loop`, or `Guides` instead. If the nearest section title already names the context, do not duplicate it as the visible toggle label. Use a short contextual label such as `Include` or, only for icon-only visual toggles, `label: false` with the product meaning in `target` and `description`.
|
|
251
256
|
|
|
252
|
-
Inline two-column groups are preferred when controls tune one close product meaning and labels/values fit. Short numeric text pairs can be inline. Related short `select` pairs can be inline, especially workflow pairs such as `Format` + `Resolution`, `Codec` + `Profile`, or `Width unit` + `Height unit`. Use stacked one-control rows only as a fit fallback when a label, selected value, or option text would clip, truncate, or lose padding; record that fallback reason in the spec or worklog. A short numeric/text field may also pair with one related plain `color` field when both configure the same entity, such as `Mask size` and `Color` inside `Mask`. `colorOpacity` never renders in inline two-column groups; if either color control has opacity, keep the controls stacked. Color
|
|
257
|
+
Inline two-column groups are preferred when controls tune one close product meaning and labels/values fit. Short numeric text pairs can be inline. Related short `select` pairs can be inline, especially workflow pairs such as `Format` + `Resolution`, `Codec` + `Profile`, or `Width unit` + `Height unit`. Use stacked one-control rows only as a fit fallback when a label, selected value, or option text would clip, truncate, or lose padding; record that fallback reason in the spec or worklog. A short numeric/text field may also pair with one related plain `color` field when both configure the same entity, such as `Mask size` and `Color` inside `Mask`. `colorOpacity` never renders in inline two-column groups; if either color control has opacity, keep the controls stacked. Color labels are semantic, not automatic: decide once for the whole color group, omit per-item labels such as `Color 1` for palette variation banks like `Accent Shades` or `Bead Colors`, and do not mix labeled and unlabeled items inside that bank. Sibling controls like `Spread` or `Randomness` do not force item labels; keep visible labels only when colors edit distinct roles such as `Fill`, `Stroke`, `Background`, `Connector`, or `Object`. Related plain color banks render two per row, and an odd trailing plain color remains half-width instead of stretching to a full row. Mixed inline rows require visible labels on every field except the required Background row and palette variation color banks whose group/section label already names the bank. Two adjacent `switch` or `checkbox` controls for the same product entity must share one inline row when both visible labels fit without truncation; the runtime auto-pairs safe adjacent toggles by target entity, and schemas should stack them only when either label is too long. A single `switch` or `checkbox` may share an inline row with one related parameter control when the toggle label fits and both controls edit the same entity; shorten the toggle label when the section title already supplies context. Toggle plus parameter rows are equal-width two-column rows: each control occupies one half, never intrinsic toggle width plus remaining space. The required Background row uses `Include` plus unlabeled background color. Schema `slider` and `rangeSlider` controls always stay stacked at full width; the only built-in exception is the paired letter-spacing and line-height footer sliders inside `fontPicker`. Do not place sibling controls for case, color, opacity, size, weight, letter spacing, or line height when the same text entity already uses `fontPicker`.
|
|
253
258
|
|
|
254
259
|
`rangeSlider` is always a full-width two-thumb control. Do not include it in `layoutGroups`. Its `defaultValue` must start with different lower and upper values, such as `[20, 80]`, so the two handles do not collapse into one apparent slider. Manual range labels accept built-in separators such as slash, hyphen, spaces, and dashes.
|
|
255
260
|
|
|
@@ -261,7 +266,7 @@ Order controls by decision flow inside each section:
|
|
|
261
266
|
- `mode`: mode, type, filter, blend, style, and preset selectors;
|
|
262
267
|
- `primary`, `spatial`, `color`: core product parameters;
|
|
263
268
|
- `strength`: intensity, opacity, scale, depth;
|
|
264
|
-
- `detail`:
|
|
269
|
+
- `detail`: noise, texture, blur, density, radius, quality;
|
|
265
270
|
- `advanced`: secondary tuning;
|
|
266
271
|
- `action`: footer actions.
|
|
267
272
|
|
|
@@ -19,7 +19,7 @@ Do not edit implementation files until this preflight is complete.
|
|
|
19
19
|
|
|
20
20
|
## Task Routing
|
|
21
21
|
|
|
22
|
-
Use the smallest reading set that covers the changed surface.
|
|
22
|
+
Use the smallest reading set that covers the changed surface. If a task touches multiple surfaces, combine every matching row instead of picking only the closest one.
|
|
23
23
|
|
|
24
24
|
| Task type | Read before editing |
|
|
25
25
|
| --- | --- |
|
|
@@ -29,7 +29,7 @@ Use the smallest reading set that covers the changed surface.
|
|
|
29
29
|
| Renderer, canvas output, visual technique | `renderer-technique.md`, `performance.md`, `acceptance-testing.md` |
|
|
30
30
|
| Timeline, keyframes, animation transport | `decision-contract.md`, `component-rules.md`, `acceptance-testing.md`, `performance.md` |
|
|
31
31
|
| Layers | `decision-contract.md`, `component-rules.md`, `acceptance-testing.md` |
|
|
32
|
-
| Export, copy, media, background | `schema-reference.md`, `component-rules.md`, `acceptance-testing.md` |
|
|
32
|
+
| Export, copy, media, background | `schema-reference.md`, `component-rules.md`, `acceptance-testing.md`, `performance.md` |
|
|
33
33
|
| Broken control, visual mismatch, failed build, export bug, performance issue | `decision-contract.md`, the relevant component/runtime doc, and the failing test/log first |
|
|
34
34
|
| Figma implementation | Use Figma MCP/design context, then `assembly-workflow.md` and the relevant component docs |
|
|
35
35
|
|
|
@@ -73,15 +73,12 @@ Choose the tier from `AGENTS.md` before editing. Use the tier to decide checks.
|
|
|
73
73
|
|
|
74
74
|
- Tier 0-1: targeted docs/typecheck/unit plus focused browser when visual.
|
|
75
75
|
- Tier 2: `pnpm verify:quick` plus relevant browser acceptance.
|
|
76
|
-
- Tier 3: `pnpm verify:quick`, targeted browser acceptance, and
|
|
77
|
-
- Tier 4: `pnpm verify:final
|
|
76
|
+
- Tier 3: `pnpm verify:quick`, targeted browser acceptance, and targeted performance scenarios only for touched workload/viewport/export paths.
|
|
77
|
+
- Tier 4: `pnpm verify:final`; add `pnpm verify:perf` only for the first working app version or explicit performance complaints, then start `pnpm dev` for the local URL.
|
|
78
78
|
|
|
79
|
-
Run a full performance checkpoint with `pnpm verify:perf` when:
|
|
79
|
+
Run a full performance checkpoint with `pnpm verify:perf` only when:
|
|
80
80
|
|
|
81
81
|
- the first working version of the app exists;
|
|
82
|
-
-
|
|
83
|
-
- a bug that previously broke functionality is fixed;
|
|
84
|
-
- a performance optimization lands;
|
|
85
|
-
- the user asks to optimize performance, fix lag, remove jank, speed up animation, or stabilize drag/zoom.
|
|
82
|
+
- the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance.
|
|
86
83
|
|
|
87
|
-
Fast feature loops
|
|
84
|
+
Fast feature loops after the first working version do not run the full performance suite by default. Renderer, canvas, animation, export, timeline, layers, `canvas.renderScale`, bug fixes, and performance-sensitive controls still need targeted functional/browser checks first, plus targeted performance scenarios only when they directly exercise the touched path. Record any skipped full performance run and reason in the worklog.
|
|
@@ -110,12 +110,49 @@ function getScenarioBudgetAssertionPattern(scenarioId: string): RegExp {
|
|
|
110
110
|
|
|
111
111
|
function getScenarioStressFixturePattern(scenarioId: string): RegExp {
|
|
112
112
|
return new RegExp(
|
|
113
|
-
`getToolcraftPerformanceStressValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
|
|
113
|
+
`(?:getToolcraftPerformanceStressValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)|dragToolcraftSliderToPerformanceStressValue\\s*\\([\\s\\S]*?(?:starterPerformance|appPerformance)|applyToolcraftPerformanceStressFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance))\\s*,\\s*(["'\`])${escapeRegExp(
|
|
114
114
|
scenarioId,
|
|
115
115
|
)}\\1`,
|
|
116
116
|
);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
function getScenarioWorkloadFixturePattern(scenarioId: string): RegExp {
|
|
120
|
+
return new RegExp(
|
|
121
|
+
`(?:getToolcraftPerformanceWorkloadValue(?:\\s*<[^>]+>)?\\s*\\(\\s*(?:starterPerformance|appPerformance)|applyToolcraftPerformanceWorkloadFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance))\\s*,\\s*(["'\`])${escapeRegExp(
|
|
122
|
+
scenarioId,
|
|
123
|
+
)}\\1`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getScenarioCustomStressFixturePattern(scenarioId: string): RegExp {
|
|
128
|
+
return new RegExp(
|
|
129
|
+
`applyToolcraftPerformanceStressFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
|
|
130
|
+
scenarioId,
|
|
131
|
+
)}\\1`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getScenarioCustomWorkloadFixturePattern(scenarioId: string): RegExp {
|
|
136
|
+
return new RegExp(
|
|
137
|
+
`applyToolcraftPerformanceWorkloadFixture\\s*\\(\\s*page\\s*,\\s*(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
|
|
138
|
+
scenarioId,
|
|
139
|
+
)}\\1`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function getScenarioSliderStressValuePattern(scenarioId: string): RegExp {
|
|
144
|
+
return new RegExp(
|
|
145
|
+
`dragToolcraftSliderToPerformanceStressValue\\s*\\([\\s\\S]*?(?:starterPerformance|appPerformance)\\s*,\\s*(["'\`])${escapeRegExp(
|
|
146
|
+
scenarioId,
|
|
147
|
+
)}\\1`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getFirstMatchIndex(source: string, pattern: RegExp): number {
|
|
152
|
+
const match = pattern.exec(source);
|
|
153
|
+
return match?.index ?? -1;
|
|
154
|
+
}
|
|
155
|
+
|
|
119
156
|
test("browser performance matrix points at real Playwright tests", () => {
|
|
120
157
|
const browserTestSources = readSiblingBrowserTestSources();
|
|
121
158
|
|
|
@@ -154,15 +191,71 @@ test("browser performance tests use real Toolcraft interactions", () => {
|
|
|
154
191
|
continue;
|
|
155
192
|
}
|
|
156
193
|
|
|
157
|
-
if (scenario.workload) {
|
|
194
|
+
if (scenario.workload || scenario.stress === true || scenario.stressFixture) {
|
|
158
195
|
expect(
|
|
159
196
|
scenario.stressFixture,
|
|
160
|
-
`${scenario.id}
|
|
197
|
+
`${scenario.id} stress scenario must declare stressFixture so browser tests cannot use toy inputs.`,
|
|
161
198
|
).toBeDefined();
|
|
162
199
|
expect(
|
|
163
200
|
browserTestSource,
|
|
164
201
|
`${scenario.id} must read the declared stress fixture with getToolcraftPerformanceStressValue(appPerformance, "${scenario.id}") before measuring performance.`,
|
|
165
202
|
).toMatch(getScenarioStressFixturePattern(scenario.id));
|
|
203
|
+
|
|
204
|
+
if (
|
|
205
|
+
scenario.stressFixture?.kind === "custom" &&
|
|
206
|
+
typeof scenario.stressFixture.value === "object" &&
|
|
207
|
+
scenario.stressFixture.value !== null &&
|
|
208
|
+
!Array.isArray(scenario.stressFixture.value)
|
|
209
|
+
) {
|
|
210
|
+
expect(
|
|
211
|
+
browserTestSource,
|
|
212
|
+
`${scenario.id} custom stress fixture must be applied with applyToolcraftPerformanceStressFixture so every heavy-state key is mapped through the real UI before measurement.`,
|
|
213
|
+
).toMatch(getScenarioCustomStressFixturePattern(scenario.id));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (scenario.workloadFixture) {
|
|
218
|
+
const workloadFixtureIndex = getFirstMatchIndex(
|
|
219
|
+
browserTestSource,
|
|
220
|
+
getScenarioWorkloadFixturePattern(scenario.id),
|
|
221
|
+
);
|
|
222
|
+
const stressFixtureIndex = getFirstMatchIndex(
|
|
223
|
+
browserTestSource,
|
|
224
|
+
getScenarioStressFixturePattern(scenario.id),
|
|
225
|
+
);
|
|
226
|
+
const budgetAssertionIndex = getFirstMatchIndex(
|
|
227
|
+
browserTestSource,
|
|
228
|
+
getScenarioBudgetAssertionPattern(scenario.id),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(
|
|
232
|
+
browserTestSource,
|
|
233
|
+
`${scenario.id} must apply the declared workloadFixture with getToolcraftPerformanceWorkloadValue(appPerformance, "${scenario.id}") or applyToolcraftPerformanceWorkloadFixture before measuring performance.`,
|
|
234
|
+
).toMatch(getScenarioWorkloadFixturePattern(scenario.id));
|
|
235
|
+
expect(
|
|
236
|
+
workloadFixtureIndex,
|
|
237
|
+
`${scenario.id} must apply workloadFixture before stressFixture so the measured control runs inside the heavy app baseline.`,
|
|
238
|
+
).toBeGreaterThanOrEqual(0);
|
|
239
|
+
expect(
|
|
240
|
+
stressFixtureIndex,
|
|
241
|
+
`${scenario.id} must read/apply stressFixture after workloadFixture.`,
|
|
242
|
+
).toBeGreaterThan(workloadFixtureIndex);
|
|
243
|
+
expect(
|
|
244
|
+
budgetAssertionIndex,
|
|
245
|
+
`${scenario.id} must assert budget only after workloadFixture is applied.`,
|
|
246
|
+
).toBeGreaterThan(workloadFixtureIndex);
|
|
247
|
+
|
|
248
|
+
if (
|
|
249
|
+
scenario.workloadFixture.kind === "custom" &&
|
|
250
|
+
typeof scenario.workloadFixture.value === "object" &&
|
|
251
|
+
scenario.workloadFixture.value !== null &&
|
|
252
|
+
!Array.isArray(scenario.workloadFixture.value)
|
|
253
|
+
) {
|
|
254
|
+
expect(
|
|
255
|
+
browserTestSource,
|
|
256
|
+
`${scenario.id} custom workload fixture must be applied with applyToolcraftPerformanceWorkloadFixture so every baseline-state key is mapped through the real UI before measurement.`,
|
|
257
|
+
).toMatch(getScenarioCustomWorkloadFixturePattern(scenario.id));
|
|
258
|
+
}
|
|
166
259
|
}
|
|
167
260
|
|
|
168
261
|
if (scenario.interaction === "control-drag") {
|
|
@@ -190,6 +283,13 @@ test("browser performance tests use real Toolcraft interactions", () => {
|
|
|
190
283
|
`${scenario.id} drags discrete slider "${scenario.controlLabel}" and must verify Toolcraft marker-budget behavior plus smooth drag.`,
|
|
191
284
|
).toMatch(/expectToolcraftDiscreteSliderDragSmoothness\s*\(/);
|
|
192
285
|
}
|
|
286
|
+
|
|
287
|
+
if (scenario.workload && scenario.controlLabel) {
|
|
288
|
+
expect(
|
|
289
|
+
browserTestSource,
|
|
290
|
+
`${scenario.id} workload slider drags must apply the exact stressFixture.value with dragToolcraftSliderToPerformanceStressValue instead of hand-dividing by max or typing a ratio.`,
|
|
291
|
+
).toMatch(getScenarioSliderStressValuePattern(scenario.id));
|
|
292
|
+
}
|
|
193
293
|
}
|
|
194
294
|
|
|
195
295
|
if (scenario.interaction === "control-change") {
|
|
@@ -284,6 +384,39 @@ test("browser performance tests use real Toolcraft interactions", () => {
|
|
|
284
384
|
).not.toMatch(/canvas\.setOffset|canvas\.panBy|canvas\.zoom|Zoom in|Zoom out|Center canvas/);
|
|
285
385
|
}
|
|
286
386
|
|
|
387
|
+
if (scenario.interaction === "timeline-playback") {
|
|
388
|
+
expect(
|
|
389
|
+
browserTestSource,
|
|
390
|
+
`${scenario.id} must use the real timeline playback UI while measuring frames.`,
|
|
391
|
+
).toMatch(/measureToolcraftInteraction\s*\([\s\S]*(Play playback|Pause playback|timeline-playback|data-slot=["']timeline-playback)/);
|
|
392
|
+
expect(
|
|
393
|
+
browserTestSource,
|
|
394
|
+
`${scenario.id} must not satisfy timeline playback performance by dispatching timeline state directly.`,
|
|
395
|
+
).not.toMatch(/timeline\.setCurrentTime|timeline\.setPlaying|timeline\.play|timeline\.pause/);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (scenario.interaction === "timeline-scrub") {
|
|
399
|
+
expect(
|
|
400
|
+
browserTestSource,
|
|
401
|
+
`${scenario.id} must scrub the real timeline UI while measuring interaction responsiveness.`,
|
|
402
|
+
).toMatch(/measureToolcraftInteraction\s*\([\s\S]*(Playback position|timeline-playback-handle|data-slot=["']timeline-playback-handle|page\.mouse\.(?:down|move|up))/);
|
|
403
|
+
expect(
|
|
404
|
+
browserTestSource,
|
|
405
|
+
`${scenario.id} must not satisfy timeline scrub performance by setting timeline time directly.`,
|
|
406
|
+
).not.toMatch(/timeline\.setCurrentTime|canvas\.setCurrentTime/);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (scenario.interaction === "mask-drag") {
|
|
410
|
+
expect(
|
|
411
|
+
browserTestSource,
|
|
412
|
+
`${scenario.id} must measure a real canvas handle/mask drag.`,
|
|
413
|
+
).toMatch(/measureToolcraftInteraction\s*\([\s\S]*dragCanvasHandle\s*\(/);
|
|
414
|
+
expect(
|
|
415
|
+
browserTestSource,
|
|
416
|
+
`${scenario.id} must not fake mask drag coverage with direct runtime state mutation.`,
|
|
417
|
+
).not.toMatch(/mask\.(?:set|update|move)|canvas\.setOffset|canvas\.panBy/);
|
|
418
|
+
}
|
|
419
|
+
|
|
287
420
|
if (scenario.interaction === "viewport-zoom-stress") {
|
|
288
421
|
expect(
|
|
289
422
|
browserTestSource,
|
|
@@ -21,6 +21,26 @@ export type ToolcraftInteractionOptions = {
|
|
|
21
21
|
settleMs?: number;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
export type ToolcraftStressFixtureApplyContext = {
|
|
25
|
+
config: ToolcraftPerformanceConfig;
|
|
26
|
+
fixture: Record<string, unknown>;
|
|
27
|
+
key: string;
|
|
28
|
+
page: Page;
|
|
29
|
+
scenarioId: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ToolcraftStressFixtureAppliers = Record<
|
|
33
|
+
string,
|
|
34
|
+
(
|
|
35
|
+
value: unknown,
|
|
36
|
+
context: ToolcraftStressFixtureApplyContext,
|
|
37
|
+
) => Promise<void> | void
|
|
38
|
+
>;
|
|
39
|
+
|
|
40
|
+
function isToolcraftStressFixtureObject(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
24
44
|
export async function startToolcraftFrameProbe(
|
|
25
45
|
page: Page,
|
|
26
46
|
): Promise<() => Promise<ToolcraftFrameProbeResult>> {
|
|
@@ -371,6 +391,161 @@ export async function dragToolcraftSliderByLabel(
|
|
|
371
391
|
await page.mouse.up();
|
|
372
392
|
}
|
|
373
393
|
|
|
394
|
+
export async function dragToolcraftSliderToValue(
|
|
395
|
+
page: Page,
|
|
396
|
+
label: string,
|
|
397
|
+
value: number,
|
|
398
|
+
): Promise<void> {
|
|
399
|
+
const field = await getToolcraftFieldByLabel(page, label);
|
|
400
|
+
const slider = field.locator('[data-slot="slider"], [role="slider"]').first();
|
|
401
|
+
|
|
402
|
+
await expect(slider, `Toolcraft slider "${label}" should be visible`).toBeVisible();
|
|
403
|
+
|
|
404
|
+
const range = await slider.evaluate((element) => {
|
|
405
|
+
const htmlElement = element as HTMLElement;
|
|
406
|
+
const min = Number(
|
|
407
|
+
htmlElement.getAttribute("aria-valuemin") ??
|
|
408
|
+
(htmlElement as HTMLInputElement).min ??
|
|
409
|
+
"0",
|
|
410
|
+
);
|
|
411
|
+
const max = Number(
|
|
412
|
+
htmlElement.getAttribute("aria-valuemax") ??
|
|
413
|
+
(htmlElement as HTMLInputElement).max ??
|
|
414
|
+
"100",
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
max: Number.isFinite(max) ? max : 100,
|
|
419
|
+
min: Number.isFinite(min) ? min : 0,
|
|
420
|
+
};
|
|
421
|
+
});
|
|
422
|
+
const denominator = range.max - range.min;
|
|
423
|
+
const ratio = denominator === 0 ? 0 : (value - range.min) / denominator;
|
|
424
|
+
|
|
425
|
+
await dragToolcraftSliderByLabel(page, label, Math.min(1, Math.max(0, ratio)));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export async function dragToolcraftSliderToPerformanceStressValue(
|
|
429
|
+
page: Page,
|
|
430
|
+
label: string,
|
|
431
|
+
config: ToolcraftPerformanceConfig,
|
|
432
|
+
scenarioId: string,
|
|
433
|
+
): Promise<void> {
|
|
434
|
+
const value = getToolcraftPerformanceStressValue(config, scenarioId);
|
|
435
|
+
|
|
436
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
437
|
+
throw new Error(
|
|
438
|
+
`Toolcraft performance scenario "${scenarioId}" must provide a numeric stressFixture.value for slider "${label}".`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
await dragToolcraftSliderToValue(page, label, value);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function applyToolcraftPerformanceStressFixture(
|
|
446
|
+
page: Page,
|
|
447
|
+
config: ToolcraftPerformanceConfig,
|
|
448
|
+
scenarioId: string,
|
|
449
|
+
appliers: ToolcraftStressFixtureAppliers,
|
|
450
|
+
): Promise<Record<string, unknown>> {
|
|
451
|
+
const fixture = getToolcraftPerformanceStressValue(config, scenarioId);
|
|
452
|
+
|
|
453
|
+
if (!isToolcraftStressFixtureObject(fixture)) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`Toolcraft performance scenario "${scenarioId}" must provide an object stressFixture.value for combined fixture application.`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const fixtureKeys = Object.keys(fixture);
|
|
460
|
+
if (fixtureKeys.length === 0) {
|
|
461
|
+
throw new Error(
|
|
462
|
+
`Toolcraft performance scenario "${scenarioId}" stressFixture.value must contain at least one key.`,
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
|
|
467
|
+
if (missingKeys.length > 0) {
|
|
468
|
+
throw new Error(
|
|
469
|
+
`Toolcraft performance scenario "${scenarioId}" is missing fixture appliers for: ${missingKeys.join(
|
|
470
|
+
", ",
|
|
471
|
+
)}.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
|
|
476
|
+
if (extraKeys.length > 0) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Toolcraft performance scenario "${scenarioId}" declares fixture appliers not present in stressFixture.value: ${extraKeys.join(
|
|
479
|
+
", ",
|
|
480
|
+
)}.`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const key of fixtureKeys) {
|
|
485
|
+
await appliers[key]!(fixture[key], {
|
|
486
|
+
config,
|
|
487
|
+
fixture,
|
|
488
|
+
key,
|
|
489
|
+
page,
|
|
490
|
+
scenarioId,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return fixture;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function applyToolcraftPerformanceWorkloadFixture(
|
|
498
|
+
page: Page,
|
|
499
|
+
config: ToolcraftPerformanceConfig,
|
|
500
|
+
scenarioId: string,
|
|
501
|
+
appliers: ToolcraftStressFixtureAppliers,
|
|
502
|
+
): Promise<Record<string, unknown>> {
|
|
503
|
+
const fixture = getToolcraftPerformanceWorkloadValue(config, scenarioId);
|
|
504
|
+
|
|
505
|
+
if (!isToolcraftStressFixtureObject(fixture)) {
|
|
506
|
+
throw new Error(
|
|
507
|
+
`Toolcraft performance scenario "${scenarioId}" must provide an object workloadFixture.value for baseline fixture application.`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const fixtureKeys = Object.keys(fixture);
|
|
512
|
+
if (fixtureKeys.length === 0) {
|
|
513
|
+
throw new Error(
|
|
514
|
+
`Toolcraft performance scenario "${scenarioId}" workloadFixture.value must contain at least one key.`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const missingKeys = fixtureKeys.filter((key) => !appliers[key]);
|
|
519
|
+
if (missingKeys.length > 0) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`Toolcraft performance scenario "${scenarioId}" is missing workload fixture appliers for: ${missingKeys.join(
|
|
522
|
+
", ",
|
|
523
|
+
)}.`,
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const extraKeys = Object.keys(appliers).filter((key) => !fixtureKeys.includes(key));
|
|
528
|
+
if (extraKeys.length > 0) {
|
|
529
|
+
throw new Error(
|
|
530
|
+
`Toolcraft performance scenario "${scenarioId}" declares workload fixture appliers not present in workloadFixture.value: ${extraKeys.join(
|
|
531
|
+
", ",
|
|
532
|
+
)}.`,
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
for (const key of fixtureKeys) {
|
|
537
|
+
await appliers[key]!(fixture[key], {
|
|
538
|
+
config,
|
|
539
|
+
fixture,
|
|
540
|
+
key,
|
|
541
|
+
page,
|
|
542
|
+
scenarioId,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
return fixture;
|
|
547
|
+
}
|
|
548
|
+
|
|
374
549
|
export async function dragToolcraftCanvasViewport(
|
|
375
550
|
page: Page,
|
|
376
551
|
delta: { x: number; y: number } = { x: 96, y: -64 },
|
|
@@ -553,6 +728,28 @@ export function getToolcraftPerformanceStressValue<TValue = unknown>(
|
|
|
553
728
|
return scenario.stressFixture.value as TValue;
|
|
554
729
|
}
|
|
555
730
|
|
|
731
|
+
export function getToolcraftPerformanceWorkloadValue<TValue = unknown>(
|
|
732
|
+
config: ToolcraftPerformanceConfig,
|
|
733
|
+
scenarioId: string,
|
|
734
|
+
): TValue {
|
|
735
|
+
const scenario = config.scenarios.find((item) => item.id === scenarioId);
|
|
736
|
+
|
|
737
|
+
if (!scenario) {
|
|
738
|
+
throw new Error(`Toolcraft performance scenario "${scenarioId}" was not found.`);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (
|
|
742
|
+
!scenario.workloadFixture ||
|
|
743
|
+
!Object.prototype.hasOwnProperty.call(scenario.workloadFixture, "value")
|
|
744
|
+
) {
|
|
745
|
+
throw new Error(
|
|
746
|
+
`Toolcraft performance scenario "${scenarioId}" does not declare workloadFixture.value.`,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return scenario.workloadFixture.value as TValue;
|
|
751
|
+
}
|
|
752
|
+
|
|
556
753
|
export function expectToolcraftScenarioPerformanceBudget(
|
|
557
754
|
result: ToolcraftPerformanceBudgetResult,
|
|
558
755
|
config: ToolcraftPerformanceConfig,
|
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
"verify:quick": "pnpm ai:check && pnpm test",
|
|
17
17
|
"verify:ui": "pnpm test:browser",
|
|
18
18
|
"verify:perf": "pnpm test:browser:perf",
|
|
19
|
-
"verify:final": "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser
|
|
19
|
+
"verify:final": "pnpm ai:check && pnpm test && pnpm build && pnpm test:browser"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"@base-ui/react": "^1.4.1",
|
|
23
|
+
"@dnd-kit/core": "^6.3.1",
|
|
24
|
+
"@dnd-kit/sortable": "^10.0.0",
|
|
25
|
+
"@dnd-kit/utilities": "^3.2.2",
|
|
23
26
|
"@fontsource-variable/inter": "^5.2.8",
|
|
24
27
|
"@phosphor-icons/react": "^2.1.10",
|
|
25
28
|
"@repo/toolcraft-runtime": "workspace:*",
|