@pixel-point/toolcraft 0.0.9 → 0.0.11
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/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +28 -0
- package/templates/runtime/contracts/component-contracts.test.ts +148 -34
- package/templates/runtime/contracts/component-contracts.ts +79 -38
- package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
- package/templates/runtime/contracts/decision-contracts.ts +19 -6
- package/templates/runtime/export/export.test.ts +63 -0
- package/templates/runtime/export/export.ts +55 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/canvas-shell.test.tsx +58 -4
- package/templates/runtime/react/canvas-shell.tsx +31 -9
- package/templates/runtime/react/controls-panel.test.tsx +474 -27
- package/templates/runtime/react/controls-panel.tsx +71 -26
- package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
- package/templates/runtime/react/settings-transfer.test.ts +4 -0
- package/templates/runtime/react/settings-transfer.ts +6 -1
- package/templates/runtime/react/timeline-panel.test.tsx +14 -0
- package/templates/runtime/react/timeline-panel.tsx +44 -7
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
- package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
- package/templates/runtime/react/toolcraft-app.tsx +56 -37
- package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
- package/templates/runtime/schema/define-toolcraft.ts +117 -247
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +41 -0
- package/templates/runtime/state/create-template-state.test.ts +156 -0
- package/templates/runtime/state/create-template-state.ts +38 -8
- package/templates/runtime/state/media-defaults.ts +105 -0
- package/templates/runtime/state/persistence.test.ts +58 -0
- package/templates/runtime/state/persistence.ts +105 -1
- package/templates/runtime/state/reducer.test.ts +280 -4
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/timeline-loop.test.ts +71 -0
- package/templates/runtime/state/timeline-loop.ts +35 -0
- package/templates/runtime/state/types.ts +27 -0
- package/templates/runtime/testing/performance.test.ts +657 -2
- package/templates/runtime/testing/performance.ts +789 -49
- package/templates/starter/AGENTS.md +22 -16
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
- package/templates/starter/docs/toolcraft/component-rules.md +49 -37
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +30 -10
- package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
- package/templates/starter/docs/toolcraft/workflow.md +5 -2
- package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
- package/templates/starter/e2e/app-performance.spec.ts +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +1 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
- package/templates/starter/scripts/toolcraft-port.mjs +178 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
- package/templates/starter/src/app/starter-acceptance.ts +978 -81
- package/templates/starter/src/app/starter-performance.test.ts +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
- package/templates/ui/components/controls/file-drop/index.ts +6 -1
- package/templates/ui/components/controls/index.ts +2 -0
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
- package/templates/ui/components/controls/select/select-control.tsx +4 -26
- package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
ToolcraftControlSchema,
|
|
3
3
|
ResolvedToolcraftAppSchema,
|
|
4
4
|
} from "../schema/types";
|
|
5
|
+
import { isToolcraftRuntimeOwnedTarget } from "../schema/runtime-targets";
|
|
5
6
|
|
|
6
7
|
export type ToolcraftPerformanceInteraction =
|
|
7
8
|
| "animation-frame"
|
|
@@ -24,6 +25,20 @@ export type ToolcraftPerformanceCoverage = {
|
|
|
24
25
|
browserTestName: string;
|
|
25
26
|
};
|
|
26
27
|
|
|
28
|
+
export type ToolcraftBrowserCheckPreferredRunner = "agent-browser";
|
|
29
|
+
|
|
30
|
+
export type ToolcraftBrowserCheckFallbackRunner = "playwright";
|
|
31
|
+
|
|
32
|
+
export type ToolcraftBrowserCheckFallbackCondition =
|
|
33
|
+
| "agent-browser-unavailable"
|
|
34
|
+
| "ci";
|
|
35
|
+
|
|
36
|
+
export type ToolcraftBrowserCheckPolicy = {
|
|
37
|
+
fallbackRunner: ToolcraftBrowserCheckFallbackRunner;
|
|
38
|
+
fallbackWhen: readonly ToolcraftBrowserCheckFallbackCondition[];
|
|
39
|
+
preferredRunner: ToolcraftBrowserCheckPreferredRunner;
|
|
40
|
+
};
|
|
41
|
+
|
|
27
42
|
export type ToolcraftPerformanceBudget = {
|
|
28
43
|
maxExportMs?: number;
|
|
29
44
|
maxFrameGapMs?: number;
|
|
@@ -48,8 +63,42 @@ export type ToolcraftPerformanceFixtureKind =
|
|
|
48
63
|
| "max-value"
|
|
49
64
|
| "media";
|
|
50
65
|
|
|
66
|
+
export type ToolcraftPerformanceLoadMetric =
|
|
67
|
+
| "canvas-area"
|
|
68
|
+
| "count"
|
|
69
|
+
| "custom"
|
|
70
|
+
| "media-area"
|
|
71
|
+
| "numeric-max"
|
|
72
|
+
| "numeric-min"
|
|
73
|
+
| "text-length";
|
|
74
|
+
|
|
75
|
+
export type ToolcraftPerformanceUserFacingRange =
|
|
76
|
+
| "experimental-above-smooth"
|
|
77
|
+
| "fully-guaranteed";
|
|
78
|
+
|
|
79
|
+
export type ToolcraftPerformanceLoadEvidence = {
|
|
80
|
+
attemptedTarget: unknown;
|
|
81
|
+
decision: string;
|
|
82
|
+
measuredResult: string;
|
|
83
|
+
optimizationAttempted?: string;
|
|
84
|
+
result: "failed" | "passed";
|
|
85
|
+
scenarioId: string;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type ToolcraftPerformanceLoadProfile = {
|
|
89
|
+
degradationStepPercent?: 10;
|
|
90
|
+
evidence?: readonly ToolcraftPerformanceLoadEvidence[];
|
|
91
|
+
hardLimit: unknown;
|
|
92
|
+
metric: ToolcraftPerformanceLoadMetric;
|
|
93
|
+
smoothTarget: unknown;
|
|
94
|
+
smoothTargetRatio: number;
|
|
95
|
+
target: string;
|
|
96
|
+
userFacingRange: ToolcraftPerformanceUserFacingRange;
|
|
97
|
+
};
|
|
98
|
+
|
|
51
99
|
export type ToolcraftPerformanceFixture = {
|
|
52
100
|
kind: ToolcraftPerformanceFixtureKind;
|
|
101
|
+
loadProfile?: ToolcraftPerformanceLoadProfile;
|
|
53
102
|
minChars?: number;
|
|
54
103
|
minCount?: number;
|
|
55
104
|
minLines?: number;
|
|
@@ -162,11 +211,20 @@ export type ToolcraftRendererLayer = {
|
|
|
162
211
|
uiSelector?: string;
|
|
163
212
|
};
|
|
164
213
|
|
|
214
|
+
export type ToolcraftRendererMeasuredAlternativeEvidence = {
|
|
215
|
+
alternativeStrategy: "webgl" | "webgpu";
|
|
216
|
+
decision: string;
|
|
217
|
+
fixture: string;
|
|
218
|
+
measuredResult: string;
|
|
219
|
+
scenarioId: string;
|
|
220
|
+
};
|
|
221
|
+
|
|
165
222
|
export type ToolcraftRendererTechnique = {
|
|
166
223
|
exportRenderer: ToolcraftExportRenderer;
|
|
167
224
|
fidelityRisks: readonly string[];
|
|
168
225
|
intentionalRasterizationReason?: string;
|
|
169
226
|
layers?: readonly ToolcraftRendererLayer[];
|
|
227
|
+
measuredAlternativeEvidence?: readonly ToolcraftRendererMeasuredAlternativeEvidence[];
|
|
170
228
|
performanceRisks: readonly string[];
|
|
171
229
|
previewExportDifferenceReason?: string;
|
|
172
230
|
previewRenderer: ToolcraftPreviewRenderer;
|
|
@@ -241,6 +299,7 @@ export type ToolcraftRendererPipeline = {
|
|
|
241
299
|
};
|
|
242
300
|
|
|
243
301
|
export type ToolcraftPerformanceConfig = {
|
|
302
|
+
browserCheckPolicy?: ToolcraftBrowserCheckPolicy;
|
|
244
303
|
rendererPipeline?: ToolcraftRendererPipeline;
|
|
245
304
|
rendererStrategy: ToolcraftRendererStrategy;
|
|
246
305
|
rendererTechnique?: ToolcraftRendererTechnique;
|
|
@@ -271,10 +330,25 @@ const maxPerformanceBudgetCaps: Required<ToolcraftPerformanceBudget> = {
|
|
|
271
330
|
maxRenderMs: 2000,
|
|
272
331
|
};
|
|
273
332
|
|
|
333
|
+
export const defaultToolcraftBrowserCheckPolicy: ToolcraftBrowserCheckPolicy = {
|
|
334
|
+
fallbackRunner: "playwright",
|
|
335
|
+
fallbackWhen: ["agent-browser-unavailable", "ci"],
|
|
336
|
+
preferredRunner: "agent-browser",
|
|
337
|
+
};
|
|
338
|
+
|
|
274
339
|
export function defineToolcraftPerformance(
|
|
275
340
|
config: ToolcraftPerformanceConfig,
|
|
276
|
-
): ToolcraftPerformanceConfig {
|
|
277
|
-
return
|
|
341
|
+
): ToolcraftPerformanceConfig & { browserCheckPolicy: ToolcraftBrowserCheckPolicy } {
|
|
342
|
+
return {
|
|
343
|
+
...config,
|
|
344
|
+
browserCheckPolicy: {
|
|
345
|
+
...defaultToolcraftBrowserCheckPolicy,
|
|
346
|
+
...config.browserCheckPolicy,
|
|
347
|
+
fallbackWhen:
|
|
348
|
+
config.browserCheckPolicy?.fallbackWhen ??
|
|
349
|
+
defaultToolcraftBrowserCheckPolicy.fallbackWhen,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
278
352
|
}
|
|
279
353
|
|
|
280
354
|
const workloadControlPattern =
|
|
@@ -297,6 +371,42 @@ const performanceFixtureKinds = new Set<ToolcraftPerformanceFixtureKind>([
|
|
|
297
371
|
"media",
|
|
298
372
|
]);
|
|
299
373
|
|
|
374
|
+
const performanceLoadMetrics = new Set<ToolcraftPerformanceLoadMetric>([
|
|
375
|
+
"canvas-area",
|
|
376
|
+
"count",
|
|
377
|
+
"custom",
|
|
378
|
+
"media-area",
|
|
379
|
+
"numeric-max",
|
|
380
|
+
"numeric-min",
|
|
381
|
+
"text-length",
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
const performanceUserFacingRanges = new Set<ToolcraftPerformanceUserFacingRange>([
|
|
385
|
+
"experimental-above-smooth",
|
|
386
|
+
"fully-guaranteed",
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
const browserCheckPreferredRunners = new Set<ToolcraftBrowserCheckPreferredRunner>([
|
|
390
|
+
"agent-browser",
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
const browserCheckFallbackRunners = new Set<ToolcraftBrowserCheckFallbackRunner>([
|
|
394
|
+
"playwright",
|
|
395
|
+
]);
|
|
396
|
+
|
|
397
|
+
const browserCheckFallbackConditions = new Set<ToolcraftBrowserCheckFallbackCondition>([
|
|
398
|
+
"agent-browser-unavailable",
|
|
399
|
+
"ci",
|
|
400
|
+
]);
|
|
401
|
+
|
|
402
|
+
const loadProfileRequiredFixtureKinds = new Set<ToolcraftPerformanceFixtureKind>([
|
|
403
|
+
"high-density",
|
|
404
|
+
"large-canvas",
|
|
405
|
+
"many-items",
|
|
406
|
+
"max-value",
|
|
407
|
+
"media",
|
|
408
|
+
]);
|
|
409
|
+
|
|
300
410
|
const renderPassKinds = new Set<ToolcraftRenderPassKind>([
|
|
301
411
|
"decode",
|
|
302
412
|
"preprocess",
|
|
@@ -401,7 +511,10 @@ export function collectToolcraftPerformanceSensitiveControls(
|
|
|
401
511
|
): ToolcraftPerformanceSensitiveControl[] {
|
|
402
512
|
return (schema.panels.controls?.sections ?? []).flatMap((section) =>
|
|
403
513
|
Object.entries(section.controls)
|
|
404
|
-
.filter(
|
|
514
|
+
.filter(
|
|
515
|
+
([, control]) =>
|
|
516
|
+
!isToolcraftRuntimeOwnedTarget(control.target) && isPotentialWorkloadControl(control),
|
|
517
|
+
)
|
|
405
518
|
.map(([controlId, control]) => ({
|
|
406
519
|
control,
|
|
407
520
|
controlId,
|
|
@@ -417,6 +530,7 @@ function collectToolcraftPerformanceRoleConflicts(
|
|
|
417
530
|
Object.entries(section.controls)
|
|
418
531
|
.filter(
|
|
419
532
|
([, control]) =>
|
|
533
|
+
!isToolcraftRuntimeOwnedTarget(control.target) &&
|
|
420
534
|
control.performanceRole === "responsiveness" &&
|
|
421
535
|
isSemanticallyWorkloadControl(control),
|
|
422
536
|
)
|
|
@@ -436,6 +550,8 @@ export function collectToolcraftUnclassifiedPerformanceControls(
|
|
|
436
550
|
.filter(
|
|
437
551
|
([, control]) =>
|
|
438
552
|
control.type !== "panelActions" &&
|
|
553
|
+
control.type !== "settingsTransfer" &&
|
|
554
|
+
!isToolcraftRuntimeOwnedTarget(control.target) &&
|
|
439
555
|
control.performanceRole !== "workload" &&
|
|
440
556
|
control.performanceRole !== "responsiveness",
|
|
441
557
|
)
|
|
@@ -455,6 +571,67 @@ function hasMinDefaultMax(values: ToolcraftPerformanceScenario["values"]): boole
|
|
|
455
571
|
return values !== undefined && "default" in values && "min" in values && "max" in values;
|
|
456
572
|
}
|
|
457
573
|
|
|
574
|
+
function getBrowserCheckPolicyErrors(config: ToolcraftPerformanceConfig): string[] {
|
|
575
|
+
const errors: string[] = [];
|
|
576
|
+
const policy = config.browserCheckPolicy ?? defaultToolcraftBrowserCheckPolicy;
|
|
577
|
+
const fallbackWhen = policy.fallbackWhen as unknown;
|
|
578
|
+
|
|
579
|
+
if (
|
|
580
|
+
!browserCheckPreferredRunners.has(
|
|
581
|
+
policy.preferredRunner as ToolcraftBrowserCheckPreferredRunner,
|
|
582
|
+
)
|
|
583
|
+
) {
|
|
584
|
+
errors.push(
|
|
585
|
+
'browserCheckPolicy.preferredRunner must be "agent-browser" so AI agents use their controlled browser before fallback automation.',
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (
|
|
590
|
+
!browserCheckFallbackRunners.has(
|
|
591
|
+
policy.fallbackRunner as ToolcraftBrowserCheckFallbackRunner,
|
|
592
|
+
)
|
|
593
|
+
) {
|
|
594
|
+
errors.push(
|
|
595
|
+
'browserCheckPolicy.fallbackRunner must be "playwright" so generated apps keep a portable CI/non-agent fallback.',
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!Array.isArray(fallbackWhen)) {
|
|
600
|
+
errors.push(
|
|
601
|
+
'browserCheckPolicy.fallbackWhen must include "agent-browser-unavailable" and "ci".',
|
|
602
|
+
);
|
|
603
|
+
return errors;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
for (const condition of fallbackWhen) {
|
|
607
|
+
if (
|
|
608
|
+
!browserCheckFallbackConditions.has(
|
|
609
|
+
condition as ToolcraftBrowserCheckFallbackCondition,
|
|
610
|
+
)
|
|
611
|
+
) {
|
|
612
|
+
errors.push(
|
|
613
|
+
`browserCheckPolicy.fallbackWhen contains unsupported condition "${String(
|
|
614
|
+
condition,
|
|
615
|
+
)}".`,
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (!fallbackWhen.includes("agent-browser-unavailable")) {
|
|
621
|
+
errors.push(
|
|
622
|
+
'browserCheckPolicy.fallbackWhen must include "agent-browser-unavailable" so Playwright is only used when no agent browser exists.',
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!fallbackWhen.includes("ci")) {
|
|
627
|
+
errors.push(
|
|
628
|
+
'browserCheckPolicy.fallbackWhen must include "ci" so CI/non-agent automation has a portable performance runner.',
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return errors;
|
|
633
|
+
}
|
|
634
|
+
|
|
458
635
|
function getSliderDragControlType(
|
|
459
636
|
control: ToolcraftControlSchema | undefined,
|
|
460
637
|
): "rangeSlider" | "slider" | null {
|
|
@@ -504,6 +681,503 @@ function getTextLineCount(value: string): number {
|
|
|
504
681
|
return value.split(/\r\n|\r|\n/).length;
|
|
505
682
|
}
|
|
506
683
|
|
|
684
|
+
function getPerformanceFixtureLargeTextValueErrors(
|
|
685
|
+
scenarioId: string,
|
|
686
|
+
valueLabel: string,
|
|
687
|
+
value: unknown,
|
|
688
|
+
options: {
|
|
689
|
+
minChars?: number;
|
|
690
|
+
minLines?: number;
|
|
691
|
+
} = {},
|
|
692
|
+
): string[] {
|
|
693
|
+
const errors: string[] = [];
|
|
694
|
+
|
|
695
|
+
if (typeof value !== "string") {
|
|
696
|
+
errors.push(`${scenarioId} ${valueLabel} must be a string.`);
|
|
697
|
+
return errors;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const minChars = options.minChars ?? largeTextStressMinChars;
|
|
701
|
+
const minLines = options.minLines ?? largeTextStressMinLines;
|
|
702
|
+
|
|
703
|
+
if (minChars < largeTextStressMinChars) {
|
|
704
|
+
errors.push(`${scenarioId} ${valueLabel}.minChars must be >= ${largeTextStressMinChars}.`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
if (minLines < largeTextStressMinLines) {
|
|
708
|
+
errors.push(`${scenarioId} ${valueLabel}.minLines must be >= ${largeTextStressMinLines}.`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (value.length < minChars) {
|
|
712
|
+
errors.push(`${scenarioId} ${valueLabel} must contain at least ${minChars} characters.`);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (getTextLineCount(value) < minLines) {
|
|
716
|
+
errors.push(`${scenarioId} ${valueLabel} must contain at least ${minLines} lines.`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return errors;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function getLastFixturePathSegment(path: string): string {
|
|
723
|
+
return path.split(/[._-]/).filter(Boolean).at(-1) ?? path;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function isCustomLargeTextFixturePath(path: string): boolean {
|
|
727
|
+
return /^(?:content|text|prompt|code|script|shader|template|json|css)$/i.test(
|
|
728
|
+
getLastFixturePathSegment(path),
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function isCustomMediaFixturePath(path: string): boolean {
|
|
733
|
+
return /(?:^|[._-])(?:media|image|video|sourceMedia|sourceImage)(?:$|[._-])/i.test(path);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function isCustomRenderScaleFixturePath(path: string): boolean {
|
|
737
|
+
return /^(?:canvas\.)?renderScale$|(?:^|[._-])resolutionScale$/i.test(path);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function isCustomCountFixturePath(path: string): boolean {
|
|
741
|
+
return /(?:^|[._-])(?:count|items|layers|particles|points|rows|columns|density|detail|quality|samples|iterations)(?:$|[._-])/i.test(
|
|
742
|
+
path,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function customFixtureValueNeedsLoadProfile(value: unknown, pathPrefix = ""): boolean {
|
|
747
|
+
if (!isPerformanceFixtureObjectValue(value)) {
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return Object.entries(value).some(([key, itemValue]) => {
|
|
752
|
+
const itemPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
|
753
|
+
|
|
754
|
+
if (
|
|
755
|
+
isCustomLargeTextFixturePath(itemPath) ||
|
|
756
|
+
isCustomMediaFixturePath(itemPath) ||
|
|
757
|
+
isCustomRenderScaleFixturePath(itemPath) ||
|
|
758
|
+
isCustomCountFixturePath(itemPath)
|
|
759
|
+
) {
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (typeof itemValue === "number" && Number.isFinite(itemValue)) {
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return customFixtureValueNeedsLoadProfile(itemValue, itemPath);
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function getSortedJsonValue(value: unknown): unknown {
|
|
772
|
+
if (Array.isArray(value)) {
|
|
773
|
+
return value.map(getSortedJsonValue);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (isPerformanceFixtureObjectValue(value)) {
|
|
777
|
+
return Object.fromEntries(
|
|
778
|
+
Object.entries(value)
|
|
779
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
780
|
+
.map(([key, itemValue]) => [key, getSortedJsonValue(itemValue)]),
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
return value;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function arePerformanceFixtureValuesEqual(left: unknown, right: unknown): boolean {
|
|
788
|
+
return JSON.stringify(getSortedJsonValue(left)) === JSON.stringify(getSortedJsonValue(right));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function getNumericFixtureValue(value: unknown): number | null {
|
|
792
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function getCountFixtureValue(value: unknown): number | null {
|
|
796
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
797
|
+
return value;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (Array.isArray(value)) {
|
|
801
|
+
return value.length;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (isPerformanceFixtureObjectValue(value)) {
|
|
805
|
+
const count = Number(value.count ?? value.items ?? value.length);
|
|
806
|
+
|
|
807
|
+
if (Number.isFinite(count)) {
|
|
808
|
+
return count;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (Array.isArray(value.items)) {
|
|
812
|
+
return value.items.length;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function getAreaFixtureDimensions(value: unknown): { height: number; width: number } | null {
|
|
820
|
+
return getMediaStressFixtureDimensions(value);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function getAreaFixtureValue(value: unknown): number | null {
|
|
824
|
+
const dimensions = getAreaFixtureDimensions(value);
|
|
825
|
+
|
|
826
|
+
if (!dimensions) {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
return dimensions.width * dimensions.height;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function formatLoadProfileRatio(value: number): string {
|
|
834
|
+
return Number(value.toFixed(4)).toString();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function getLoadProfileActualSmoothRatio(
|
|
838
|
+
profile: ToolcraftPerformanceLoadProfile,
|
|
839
|
+
): number | null {
|
|
840
|
+
if (profile.metric === "numeric-max") {
|
|
841
|
+
const hardLimit = getNumericFixtureValue(profile.hardLimit);
|
|
842
|
+
const smoothTarget = getNumericFixtureValue(profile.smoothTarget);
|
|
843
|
+
|
|
844
|
+
return hardLimit !== null && smoothTarget !== null && hardLimit > 0
|
|
845
|
+
? smoothTarget / hardLimit
|
|
846
|
+
: null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (profile.metric === "numeric-min") {
|
|
850
|
+
const hardLimit = getNumericFixtureValue(profile.hardLimit);
|
|
851
|
+
const smoothTarget = getNumericFixtureValue(profile.smoothTarget);
|
|
852
|
+
|
|
853
|
+
return hardLimit !== null && smoothTarget !== null && smoothTarget > 0
|
|
854
|
+
? hardLimit / smoothTarget
|
|
855
|
+
: null;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (profile.metric === "count") {
|
|
859
|
+
const hardLimit = getCountFixtureValue(profile.hardLimit);
|
|
860
|
+
const smoothTarget = getCountFixtureValue(profile.smoothTarget);
|
|
861
|
+
|
|
862
|
+
return hardLimit !== null && smoothTarget !== null && hardLimit > 0
|
|
863
|
+
? smoothTarget / hardLimit
|
|
864
|
+
: null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (profile.metric === "canvas-area" || profile.metric === "media-area") {
|
|
868
|
+
const hardArea = getAreaFixtureValue(profile.hardLimit);
|
|
869
|
+
const smoothArea = getAreaFixtureValue(profile.smoothTarget);
|
|
870
|
+
|
|
871
|
+
return hardArea !== null && smoothArea !== null && hardArea > 0
|
|
872
|
+
? smoothArea / hardArea
|
|
873
|
+
: null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function getLoadProfileSmoothTargetRatioError(
|
|
880
|
+
label: string,
|
|
881
|
+
profile: ToolcraftPerformanceLoadProfile,
|
|
882
|
+
): string | null {
|
|
883
|
+
const declaredRatio = profile.smoothTargetRatio;
|
|
884
|
+
|
|
885
|
+
if (
|
|
886
|
+
typeof declaredRatio !== "number" ||
|
|
887
|
+
!Number.isFinite(declaredRatio) ||
|
|
888
|
+
declaredRatio <= 0 ||
|
|
889
|
+
declaredRatio > 1
|
|
890
|
+
) {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (
|
|
895
|
+
declaredRatio === 1 &&
|
|
896
|
+
!arePerformanceFixtureValuesEqual(profile.smoothTarget, profile.hardLimit)
|
|
897
|
+
) {
|
|
898
|
+
return `${label}.smoothTargetRatio 1 requires smoothTarget to equal hardLimit.`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const actualRatio = getLoadProfileActualSmoothRatio(profile);
|
|
902
|
+
|
|
903
|
+
if (actualRatio === null) {
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
const tolerance = 0.0001;
|
|
908
|
+
|
|
909
|
+
if (declaredRatio === 1) {
|
|
910
|
+
return Math.abs(actualRatio - 1) <= tolerance
|
|
911
|
+
? null
|
|
912
|
+
: `${label}.smoothTargetRatio 1 must describe the actual smooth target ratio ${formatLoadProfileRatio(actualRatio)}.`;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const previousStepRatio = Math.min(1, declaredRatio + 0.1);
|
|
916
|
+
|
|
917
|
+
return actualRatio >= declaredRatio - tolerance &&
|
|
918
|
+
actualRatio < previousStepRatio - tolerance
|
|
919
|
+
? null
|
|
920
|
+
: `${label}.smoothTargetRatio ${formatLoadProfileRatio(declaredRatio)} must describe the actual smooth target ratio ${formatLoadProfileRatio(actualRatio)}.`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function fixtureRequiresLoadProfile(fixture: ToolcraftPerformanceFixture): boolean {
|
|
924
|
+
return (
|
|
925
|
+
loadProfileRequiredFixtureKinds.has(fixture.kind) ||
|
|
926
|
+
(fixture.kind === "custom" && customFixtureValueNeedsLoadProfile(fixture.value))
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function getLoadProfileRatioStepCount(ratio: number): number {
|
|
931
|
+
return Math.round((1 - ratio) / 0.1);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function isTenPercentRatioStep(ratio: number): boolean {
|
|
935
|
+
return Math.abs(ratio * 10 - Math.round(ratio * 10)) < 0.0001;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function getSchemaNumericHardLimit(
|
|
939
|
+
control: ToolcraftControlSchema | undefined,
|
|
940
|
+
metric: ToolcraftPerformanceLoadMetric,
|
|
941
|
+
): number | null {
|
|
942
|
+
if (!control || (metric !== "numeric-max" && metric !== "numeric-min")) {
|
|
943
|
+
return null;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const value = metric === "numeric-max" ? control.max : control.min;
|
|
947
|
+
|
|
948
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function getPerformanceLoadProfileErrors(
|
|
952
|
+
scenario: ToolcraftPerformanceScenario,
|
|
953
|
+
fieldName: "stressFixture" | "workloadFixture",
|
|
954
|
+
fixture: ToolcraftPerformanceFixture,
|
|
955
|
+
control: ToolcraftControlSchema | undefined,
|
|
956
|
+
): string[] {
|
|
957
|
+
const errors: string[] = [];
|
|
958
|
+
const profile = fixture.loadProfile;
|
|
959
|
+
|
|
960
|
+
if (!profile) {
|
|
961
|
+
return fixtureRequiresLoadProfile(fixture)
|
|
962
|
+
? [
|
|
963
|
+
`${scenario.id} ${fieldName}.loadProfile must declare hardLimit, smoothTarget, and smoothTargetRatio so performance workload is a measured smooth target rather than a hidden toy fixture.`,
|
|
964
|
+
]
|
|
965
|
+
: [];
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const label = `${scenario.id} ${fieldName}.loadProfile`;
|
|
969
|
+
|
|
970
|
+
if (!profile.target.trim()) {
|
|
971
|
+
errors.push(`${label}.target must name the product workload target.`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (!performanceLoadMetrics.has(profile.metric)) {
|
|
975
|
+
errors.push(`${label}.metric "${profile.metric}" is not supported.`);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (!performanceUserFacingRanges.has(profile.userFacingRange)) {
|
|
979
|
+
errors.push(`${label}.userFacingRange "${profile.userFacingRange}" is not supported.`);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (
|
|
983
|
+
typeof profile.smoothTargetRatio !== "number" ||
|
|
984
|
+
!Number.isFinite(profile.smoothTargetRatio) ||
|
|
985
|
+
profile.smoothTargetRatio <= 0 ||
|
|
986
|
+
profile.smoothTargetRatio > 1
|
|
987
|
+
) {
|
|
988
|
+
errors.push(`${label}.smoothTargetRatio must be > 0 and <= 1.`);
|
|
989
|
+
} else if (!isTenPercentRatioStep(profile.smoothTargetRatio)) {
|
|
990
|
+
errors.push(`${label}.smoothTargetRatio must use 10 percent steps such as 1, 0.9, or 0.8.`);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const smoothTargetRatioError = getLoadProfileSmoothTargetRatioError(label, profile);
|
|
994
|
+
|
|
995
|
+
if (smoothTargetRatioError) {
|
|
996
|
+
errors.push(smoothTargetRatioError);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (!hasPerformanceFixtureValue(fixture)) {
|
|
1000
|
+
errors.push(`${label}.smoothTarget cannot be checked because ${fieldName}.value is missing.`);
|
|
1001
|
+
} else if (!arePerformanceFixtureValuesEqual(profile.smoothTarget, fixture.value)) {
|
|
1002
|
+
errors.push(`${label}.smoothTarget must match ${fieldName}.value so browser tests apply the documented smooth workload target.`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const schemaHardLimit = getSchemaNumericHardLimit(control, profile.metric);
|
|
1006
|
+
|
|
1007
|
+
if (
|
|
1008
|
+
schemaHardLimit !== null &&
|
|
1009
|
+
!arePerformanceFixtureValuesEqual(profile.hardLimit, schemaHardLimit)
|
|
1010
|
+
) {
|
|
1011
|
+
errors.push(`${label}.hardLimit must match schema ${profile.metric === "numeric-max" ? "max" : "min"} ${schemaHardLimit}.`);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
if (profile.metric === "numeric-max" || profile.metric === "numeric-min") {
|
|
1015
|
+
const hardLimit = getNumericFixtureValue(profile.hardLimit);
|
|
1016
|
+
const smoothTarget = getNumericFixtureValue(profile.smoothTarget);
|
|
1017
|
+
|
|
1018
|
+
if (hardLimit === null || smoothTarget === null) {
|
|
1019
|
+
errors.push(`${label}.${profile.metric} hardLimit and smoothTarget must be numeric.`);
|
|
1020
|
+
} else if (profile.metric === "numeric-max" && smoothTarget > hardLimit) {
|
|
1021
|
+
errors.push(`${label}.smoothTarget must be <= hardLimit for numeric-max workloads.`);
|
|
1022
|
+
} else if (profile.metric === "numeric-min" && smoothTarget < hardLimit) {
|
|
1023
|
+
errors.push(`${label}.smoothTarget must be >= hardLimit for numeric-min workloads.`);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (profile.metric === "count") {
|
|
1028
|
+
const hardLimit = getCountFixtureValue(profile.hardLimit);
|
|
1029
|
+
const smoothTarget = getCountFixtureValue(profile.smoothTarget);
|
|
1030
|
+
|
|
1031
|
+
if (hardLimit === null || smoothTarget === null) {
|
|
1032
|
+
errors.push(`${label}.count hardLimit and smoothTarget must be numeric counts, arrays, or objects with count/items.`);
|
|
1033
|
+
} else if (smoothTarget > hardLimit) {
|
|
1034
|
+
errors.push(`${label}.smoothTarget count must be <= hardLimit count.`);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (profile.metric === "canvas-area" || profile.metric === "media-area") {
|
|
1039
|
+
const hardArea = getAreaFixtureValue(profile.hardLimit);
|
|
1040
|
+
const smoothArea = getAreaFixtureValue(profile.smoothTarget);
|
|
1041
|
+
|
|
1042
|
+
if (hardArea === null || smoothArea === null) {
|
|
1043
|
+
errors.push(`${label}.${profile.metric} hardLimit and smoothTarget must include numeric width and height.`);
|
|
1044
|
+
} else if (smoothArea > hardArea) {
|
|
1045
|
+
errors.push(`${label}.smoothTarget area must be <= hardLimit area.`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
if (fixture.kind === "many-items") {
|
|
1050
|
+
const smoothCount = getCountFixtureValue(fixture.value);
|
|
1051
|
+
|
|
1052
|
+
if (typeof fixture.minCount !== "number" || fixture.minCount <= 0) {
|
|
1053
|
+
errors.push(`${scenario.id} ${fieldName}.minCount must be a positive number for many-items fixtures.`);
|
|
1054
|
+
} else if (smoothCount !== null && smoothCount < fixture.minCount) {
|
|
1055
|
+
errors.push(`${scenario.id} ${fieldName}.value must include at least ${fixture.minCount} items for many-items fixtures.`);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
if (
|
|
1060
|
+
typeof profile.smoothTargetRatio === "number" &&
|
|
1061
|
+
Number.isFinite(profile.smoothTargetRatio)
|
|
1062
|
+
) {
|
|
1063
|
+
if (profile.smoothTargetRatio === 1) {
|
|
1064
|
+
if (profile.userFacingRange !== "fully-guaranteed") {
|
|
1065
|
+
errors.push(`${label}.userFacingRange must be "fully-guaranteed" when smoothTargetRatio is 1.`);
|
|
1066
|
+
}
|
|
1067
|
+
} else if (profile.smoothTargetRatio > 0 && profile.smoothTargetRatio < 1) {
|
|
1068
|
+
if (profile.degradationStepPercent !== 10) {
|
|
1069
|
+
errors.push(`${label}.degradationStepPercent must be 10 when smoothTargetRatio is below 1.`);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (profile.userFacingRange !== "experimental-above-smooth") {
|
|
1073
|
+
errors.push(`${label}.userFacingRange must be "experimental-above-smooth" when smoothTargetRatio is below 1.`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
const expectedEvidenceCount = getLoadProfileRatioStepCount(profile.smoothTargetRatio);
|
|
1077
|
+
const evidence = profile.evidence ?? [];
|
|
1078
|
+
|
|
1079
|
+
if (evidence.length < expectedEvidenceCount) {
|
|
1080
|
+
errors.push(`${label}.evidence must include failed measurements for each 10 percent step above smoothTargetRatio ${profile.smoothTargetRatio}.`);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
for (const [index, entry] of evidence.entries()) {
|
|
1084
|
+
const evidenceLabel = `${label}.evidence[${index}]`;
|
|
1085
|
+
|
|
1086
|
+
if (!entry.scenarioId.trim()) {
|
|
1087
|
+
errors.push(`${evidenceLabel}.scenarioId must name the measured scenario.`);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (entry.result !== "failed") {
|
|
1091
|
+
errors.push(`${evidenceLabel}.result must be "failed" for degraded smooth targets.`);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (!entry.measuredResult.trim()) {
|
|
1095
|
+
errors.push(`${evidenceLabel}.measuredResult must include the measured budget failure.`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (!entry.optimizationAttempted?.trim()) {
|
|
1099
|
+
errors.push(`${evidenceLabel}.optimizationAttempted must describe the optimization attempted before lowering the smooth target.`);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
if (!entry.decision.trim()) {
|
|
1103
|
+
errors.push(`${evidenceLabel}.decision must explain why this target was rejected or lowered.`);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
return errors;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
function getCustomPerformanceFixtureSemanticErrors(
|
|
1113
|
+
scenarioId: string,
|
|
1114
|
+
fieldName: "stressFixture" | "workloadFixture",
|
|
1115
|
+
value: Record<string, unknown>,
|
|
1116
|
+
pathPrefix = "",
|
|
1117
|
+
): string[] {
|
|
1118
|
+
const errors: string[] = [];
|
|
1119
|
+
|
|
1120
|
+
for (const [key, itemValue] of Object.entries(value)) {
|
|
1121
|
+
const itemPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
|
1122
|
+
const itemLabel = `custom ${fieldName}.${itemPath}`;
|
|
1123
|
+
|
|
1124
|
+
if (isCustomLargeTextFixturePath(itemPath)) {
|
|
1125
|
+
errors.push(
|
|
1126
|
+
...getPerformanceFixtureLargeTextValueErrors(
|
|
1127
|
+
scenarioId,
|
|
1128
|
+
itemLabel,
|
|
1129
|
+
itemValue,
|
|
1130
|
+
),
|
|
1131
|
+
);
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (isCustomMediaFixturePath(itemPath)) {
|
|
1136
|
+
const dimensions = getMediaStressFixtureDimensions(itemValue);
|
|
1137
|
+
|
|
1138
|
+
if (!dimensions) {
|
|
1139
|
+
errors.push(
|
|
1140
|
+
`${scenarioId} ${itemLabel} must be an object with numeric width and height so browser tests can generate or load realistic media.`,
|
|
1141
|
+
);
|
|
1142
|
+
} else if (
|
|
1143
|
+
Math.max(dimensions.width, dimensions.height) < mediaStressMinLongEdge ||
|
|
1144
|
+
dimensions.width * dimensions.height < mediaStressMinArea
|
|
1145
|
+
) {
|
|
1146
|
+
errors.push(
|
|
1147
|
+
`${scenarioId} ${itemLabel} must be at least 1920x1080-equivalent; received ${dimensions.width}x${dimensions.height}.`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
continue;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
if (isCustomRenderScaleFixturePath(itemPath)) {
|
|
1154
|
+
if (
|
|
1155
|
+
typeof itemValue !== "number" ||
|
|
1156
|
+
!Number.isFinite(itemValue) ||
|
|
1157
|
+
itemValue <= 1
|
|
1158
|
+
) {
|
|
1159
|
+
errors.push(
|
|
1160
|
+
`${scenarioId} ${itemLabel} must be a numeric Resolution scale greater than 1 so browser tests prove high-resolution backing pixels.`,
|
|
1161
|
+
);
|
|
1162
|
+
}
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (isPerformanceFixtureObjectValue(itemValue)) {
|
|
1167
|
+
errors.push(
|
|
1168
|
+
...getCustomPerformanceFixtureSemanticErrors(
|
|
1169
|
+
scenarioId,
|
|
1170
|
+
fieldName,
|
|
1171
|
+
itemValue,
|
|
1172
|
+
itemPath,
|
|
1173
|
+
),
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
return errors;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
507
1181
|
function isLargeTextWorkloadControl(
|
|
508
1182
|
control: ToolcraftControlSchema | undefined,
|
|
509
1183
|
): boolean {
|
|
@@ -522,15 +1196,17 @@ function isLargeTextWorkloadControl(
|
|
|
522
1196
|
}
|
|
523
1197
|
|
|
524
1198
|
function getPerformanceFixtureShapeErrors(
|
|
525
|
-
|
|
1199
|
+
scenario: ToolcraftPerformanceScenario,
|
|
526
1200
|
fieldName: "stressFixture" | "workloadFixture",
|
|
527
1201
|
fixture: ToolcraftPerformanceFixture,
|
|
1202
|
+
control: ToolcraftControlSchema | undefined,
|
|
528
1203
|
options: {
|
|
529
1204
|
requireLargeText?: boolean;
|
|
530
1205
|
requireMedia?: boolean;
|
|
531
1206
|
} = {},
|
|
532
1207
|
): string[] {
|
|
533
1208
|
const errors: string[] = [];
|
|
1209
|
+
const scenarioId = scenario.id;
|
|
534
1210
|
|
|
535
1211
|
if (!performanceFixtureKinds.has(fixture.kind)) {
|
|
536
1212
|
errors.push(`${scenarioId} ${fieldName}.kind "${fixture.kind}" is not supported.`);
|
|
@@ -563,6 +1239,14 @@ function getPerformanceFixtureShapeErrors(
|
|
|
563
1239
|
errors.push(
|
|
564
1240
|
`${scenarioId} custom ${fieldName}.value must include at least one heavy state key.`,
|
|
565
1241
|
);
|
|
1242
|
+
} else {
|
|
1243
|
+
errors.push(
|
|
1244
|
+
...getCustomPerformanceFixtureSemanticErrors(
|
|
1245
|
+
scenarioId,
|
|
1246
|
+
fieldName,
|
|
1247
|
+
fixture.value,
|
|
1248
|
+
),
|
|
1249
|
+
);
|
|
566
1250
|
}
|
|
567
1251
|
} else if (fixture.kind === "media") {
|
|
568
1252
|
const dimensions = getMediaStressFixtureDimensions(fixture.value);
|
|
@@ -582,6 +1266,7 @@ function getPerformanceFixtureShapeErrors(
|
|
|
582
1266
|
}
|
|
583
1267
|
|
|
584
1268
|
if (!options.requireLargeText && fixture.kind !== "large-text") {
|
|
1269
|
+
errors.push(...getPerformanceLoadProfileErrors(scenario, fieldName, fixture, control));
|
|
585
1270
|
return errors;
|
|
586
1271
|
}
|
|
587
1272
|
|
|
@@ -592,37 +1277,19 @@ function getPerformanceFixtureShapeErrors(
|
|
|
592
1277
|
return errors;
|
|
593
1278
|
}
|
|
594
1279
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
);
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
if (minLines < largeTextStressMinLines) {
|
|
610
|
-
errors.push(
|
|
611
|
-
`${scenarioId} large-text ${fieldName}.minLines must be >= ${largeTextStressMinLines}.`,
|
|
612
|
-
);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
if (fixture.value.length < minChars) {
|
|
616
|
-
errors.push(
|
|
617
|
-
`${scenarioId} large-text ${fieldName}.value must contain at least ${minChars} characters.`,
|
|
618
|
-
);
|
|
619
|
-
}
|
|
1280
|
+
errors.push(
|
|
1281
|
+
...getPerformanceFixtureLargeTextValueErrors(
|
|
1282
|
+
scenarioId,
|
|
1283
|
+
`large-text ${fieldName}.value`,
|
|
1284
|
+
fixture.value,
|
|
1285
|
+
{
|
|
1286
|
+
minChars: fixture.minChars,
|
|
1287
|
+
minLines: fixture.minLines,
|
|
1288
|
+
},
|
|
1289
|
+
),
|
|
1290
|
+
);
|
|
620
1291
|
|
|
621
|
-
|
|
622
|
-
errors.push(
|
|
623
|
-
`${scenarioId} large-text ${fieldName}.value must contain at least ${minLines} lines.`,
|
|
624
|
-
);
|
|
625
|
-
}
|
|
1292
|
+
errors.push(...getPerformanceLoadProfileErrors(scenario, fieldName, fixture, control));
|
|
626
1293
|
|
|
627
1294
|
return errors;
|
|
628
1295
|
}
|
|
@@ -664,7 +1331,7 @@ function getStressFixtureErrors(
|
|
|
664
1331
|
}
|
|
665
1332
|
|
|
666
1333
|
errors.push(
|
|
667
|
-
...getPerformanceFixtureShapeErrors(scenario
|
|
1334
|
+
...getPerformanceFixtureShapeErrors(scenario, "stressFixture", fixture, control, {
|
|
668
1335
|
requireLargeText: isLargeTextWorkloadControl(control),
|
|
669
1336
|
requireMedia: isMediaImport,
|
|
670
1337
|
}),
|
|
@@ -735,9 +1402,10 @@ function getWorkloadFixtureErrors(
|
|
|
735
1402
|
|
|
736
1403
|
errors.push(
|
|
737
1404
|
...getPerformanceFixtureShapeErrors(
|
|
738
|
-
scenario
|
|
1405
|
+
scenario,
|
|
739
1406
|
"workloadFixture",
|
|
740
1407
|
fixture,
|
|
1408
|
+
control,
|
|
741
1409
|
),
|
|
742
1410
|
);
|
|
743
1411
|
|
|
@@ -940,19 +1608,48 @@ function hasDetailHeavyCanvas2DRendererLayer(
|
|
|
940
1608
|
);
|
|
941
1609
|
}
|
|
942
1610
|
|
|
1611
|
+
function getRendererMeasuredAlternativeEvidenceErrors(
|
|
1612
|
+
technique: ToolcraftRendererTechnique,
|
|
1613
|
+
): string[] {
|
|
1614
|
+
return (technique.measuredAlternativeEvidence ?? []).flatMap((evidence, index) => {
|
|
1615
|
+
const label = `rendererTechnique.measuredAlternativeEvidence[${index}]`;
|
|
1616
|
+
const errors: string[] = [];
|
|
1617
|
+
|
|
1618
|
+
if (evidence.alternativeStrategy !== "webgl" && evidence.alternativeStrategy !== "webgpu") {
|
|
1619
|
+
errors.push(`${label}.alternativeStrategy must be "webgl" or "webgpu".`);
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
if (!evidence.scenarioId.trim()) {
|
|
1623
|
+
errors.push(`${label}.scenarioId must name the measured performance scenario.`);
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
if (!evidence.fixture.trim()) {
|
|
1627
|
+
errors.push(`${label}.fixture must name the exact stress fixture that was measured.`);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (!evidence.measuredResult.trim()) {
|
|
1631
|
+
errors.push(`${label}.measuredResult must summarize measured frame, interaction, render, or long-task results.`);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
if (!evidence.decision.trim()) {
|
|
1635
|
+
errors.push(`${label}.decision must explain why the selected renderer remains appropriate.`);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return errors;
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
|
|
943
1642
|
function hasMeasuredGpuAlternativeEvidence(
|
|
944
1643
|
technique: ToolcraftRendererTechnique,
|
|
945
1644
|
): boolean {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
evidenceText,
|
|
955
|
-
)
|
|
1645
|
+
return (technique.measuredAlternativeEvidence ?? []).some(
|
|
1646
|
+
(evidence) =>
|
|
1647
|
+
(evidence.alternativeStrategy === "webgl" ||
|
|
1648
|
+
evidence.alternativeStrategy === "webgpu") &&
|
|
1649
|
+
evidence.scenarioId.trim().length > 0 &&
|
|
1650
|
+
evidence.fixture.trim().length > 0 &&
|
|
1651
|
+
evidence.measuredResult.trim().length > 0 &&
|
|
1652
|
+
evidence.decision.trim().length > 0,
|
|
956
1653
|
);
|
|
957
1654
|
}
|
|
958
1655
|
|
|
@@ -1090,6 +1787,8 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
|
|
|
1090
1787
|
errors.push("Custom renderer technique must list performance risks.");
|
|
1091
1788
|
}
|
|
1092
1789
|
|
|
1790
|
+
errors.push(...getRendererMeasuredAlternativeEvidenceErrors(technique));
|
|
1791
|
+
|
|
1093
1792
|
if (
|
|
1094
1793
|
technique.productRepresentation === "text" &&
|
|
1095
1794
|
technique.rendererWorkload !== "text-output" &&
|
|
@@ -1142,7 +1841,7 @@ function getRendererTechniqueErrors(config: ToolcraftPerformanceConfig): string[
|
|
|
1142
1841
|
!hasMeasuredGpuAlternativeEvidence(technique)
|
|
1143
1842
|
) {
|
|
1144
1843
|
errors.push(
|
|
1145
|
-
"Detail-heavy Canvas 2D renderers must
|
|
1844
|
+
"Detail-heavy Canvas 2D renderers must include rendererTechnique.measuredAlternativeEvidence for WebGL/WebGPU stress comparison before keeping the pixel work on CPU.",
|
|
1146
1845
|
);
|
|
1147
1846
|
}
|
|
1148
1847
|
|
|
@@ -1155,6 +1854,31 @@ function getPassById(
|
|
|
1155
1854
|
return new Map(pipeline.passes.map((pass) => [pass.id, pass]));
|
|
1156
1855
|
}
|
|
1157
1856
|
|
|
1857
|
+
function hasMainThreadCanvasRasterCompositePreviewPressure(
|
|
1858
|
+
config: ToolcraftPerformanceConfig,
|
|
1859
|
+
): boolean {
|
|
1860
|
+
if (config.rendererStrategy !== "canvas-2d" || !config.rendererPipeline) {
|
|
1861
|
+
return false;
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
const hasMainThreadRasterPass = config.rendererPipeline.passes.some(
|
|
1865
|
+
(pass) =>
|
|
1866
|
+
pass.runsOn === "main" &&
|
|
1867
|
+
(pass.kind === "rasterize" || pass.kind === "pixel-transform") &&
|
|
1868
|
+
(pass.output === "intermediate" || pass.output === "preview") &&
|
|
1869
|
+
(pass.quality === "full" || pass.quality === "retina"),
|
|
1870
|
+
);
|
|
1871
|
+
const hasMainThreadPreviewComposite = config.rendererPipeline.passes.some(
|
|
1872
|
+
(pass) =>
|
|
1873
|
+
pass.runsOn === "main" &&
|
|
1874
|
+
pass.kind === "composite" &&
|
|
1875
|
+
pass.output === "preview" &&
|
|
1876
|
+
(pass.quality === "full" || pass.quality === "retina"),
|
|
1877
|
+
);
|
|
1878
|
+
|
|
1879
|
+
return hasMainThreadRasterPass && hasMainThreadPreviewComposite;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1158
1882
|
function hasPipelineReference(value: string): boolean {
|
|
1159
1883
|
return value.trim().length > 0 && !vaguePipelineReferencePattern.test(value.trim());
|
|
1160
1884
|
}
|
|
@@ -1418,6 +2142,7 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
1418
2142
|
getAllSchemaControls(schema).map((control) => [control.target, control] as const),
|
|
1419
2143
|
);
|
|
1420
2144
|
|
|
2145
|
+
errors.push(...getBrowserCheckPolicyErrors(config));
|
|
1421
2146
|
errors.push(...getRendererTechniqueErrors(config));
|
|
1422
2147
|
errors.push(...getRendererPipelineErrors(schema, config));
|
|
1423
2148
|
|
|
@@ -1445,13 +2170,22 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
1445
2170
|
);
|
|
1446
2171
|
}
|
|
1447
2172
|
|
|
2173
|
+
if (
|
|
2174
|
+
hasMainThreadCanvasRasterCompositePreviewPressure(config) &&
|
|
2175
|
+
config.rendererWorkload !== "pixel-output"
|
|
2176
|
+
) {
|
|
2177
|
+
errors.push(
|
|
2178
|
+
`Canvas 2D pipelines with main-thread rasterize/composite preview pressure must use rendererWorkload "pixel-output" or move expensive passes off the main thread; received "${config.rendererWorkload}".`,
|
|
2179
|
+
);
|
|
2180
|
+
}
|
|
2181
|
+
|
|
1448
2182
|
if (
|
|
1449
2183
|
config.rendererWorkload === "pixel-output" &&
|
|
1450
2184
|
!gpuRendererStrategies.has(config.rendererStrategy) &&
|
|
1451
2185
|
(!config.rendererTechnique || !hasMeasuredGpuAlternativeEvidence(config.rendererTechnique))
|
|
1452
2186
|
) {
|
|
1453
2187
|
errors.push(
|
|
1454
|
-
`rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}". Keeping a CPU renderer requires
|
|
2188
|
+
`rendererWorkload "pixel-output" should use rendererStrategy "webgl" or "webgpu", received "${config.rendererStrategy}". Keeping a CPU renderer requires rendererTechnique.measuredAlternativeEvidence for WebGL/WebGPU stress comparison.`,
|
|
1455
2189
|
);
|
|
1456
2190
|
}
|
|
1457
2191
|
|
|
@@ -1577,7 +2311,9 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
1577
2311
|
),
|
|
1578
2312
|
);
|
|
1579
2313
|
const sensitiveTargets = new Set(
|
|
1580
|
-
collectToolcraftPerformanceSensitiveControls(schema)
|
|
2314
|
+
collectToolcraftPerformanceSensitiveControls(schema)
|
|
2315
|
+
.map((entry) => entry.target)
|
|
2316
|
+
.filter((target) => !isToolcraftRuntimeOwnedTarget(target)),
|
|
1581
2317
|
);
|
|
1582
2318
|
const performanceRoleConflicts = collectToolcraftPerformanceRoleConflicts(schema);
|
|
1583
2319
|
|
|
@@ -1623,6 +2359,10 @@ export function validateToolcraftPerformanceCoverage(
|
|
|
1623
2359
|
}
|
|
1624
2360
|
|
|
1625
2361
|
for (const target of getVisiblePerformanceControlTargets(schema)) {
|
|
2362
|
+
if (isToolcraftRuntimeOwnedTarget(target)) {
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
1626
2366
|
const targetScenarios = scenariosByTarget.get(target) ?? [];
|
|
1627
2367
|
const targetControl = controlsByTarget.get(target);
|
|
1628
2368
|
const sliderControlType = getSliderDragControlType(targetControl);
|