@pygmalionjs/pygmalion 0.6.26 → 0.6.28
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/dist-lib/{FrozenRoutePreview-BQDZOtJG.js → FrozenRoutePreview-Xm7o3urC.js} +3287 -2670
- package/dist-lib/pygmalion.js +9923 -9530
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/canvas/SectionBoxes.d.ts +2 -3
- package/dist-lib/types/canvas/useFlowSession.d.ts +3 -1
- package/dist-lib/types/editor/designImport.d.ts +42 -3
- package/dist-lib/types/editor/flowSessionScheduler.d.ts +54 -5
- package/dist-lib/types/editor/flowSessions.d.ts +49 -9
- package/dist-lib/types/editor/frameLabels.d.ts +9 -0
- package/dist-lib/types/editor/geometryStability.d.ts +122 -0
- package/dist-lib/types/editor/heldPseudoStates.d.ts +22 -0
- package/dist-lib/types/editor/interactiveSessionSurface.d.ts +13 -0
- package/dist-lib/types/editor/interactiveStates.d.ts +26 -14
- package/dist-lib/types/editor/previewBootstrap.d.ts +7 -1
- package/dist-lib/types/editor/projectRuntime.d.ts +5 -0
- package/dist-lib/types/editor/routePreview.d.ts +5 -0
- package/dist-lib/types/editor/stateSwitchMetrics.d.ts +150 -0
- package/dist-lib/types/editor/store.d.ts +57 -2
- package/dist-lib/types/editor/storyboardDiscovery.d.ts +1 -0
- package/dist-lib/types/editor/variantPrefetch.d.ts +69 -0
- package/dist-lib/types/lib.d.ts +67 -1
- package/docs/screen-state-contract.md +58 -9
- package/node/dev-mirror.mjs +80 -8
- package/node/preview-artifact-plugin.mjs +14 -1
- package/node/preview-artifact-store.mjs +39 -3
- package/node/storyboard-capture-runtime.mjs +40 -0
- package/node/vite.mjs +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { InteractiveStateDef, InteractiveStateOption } from './interactiveStates';
|
|
2
|
+
import type { PageModel } from './store';
|
|
3
|
+
/**
|
|
4
|
+
* Cheap kill switch, same shape as setCaptureSupplyMode: the host (or a
|
|
5
|
+
* console session) can turn speculation off without touching anything else.
|
|
6
|
+
* Disabling also drops any scheduled or in-progress round.
|
|
7
|
+
*/
|
|
8
|
+
export declare function setInteractiveStatePrefetchEnabled(enabled: boolean): void;
|
|
9
|
+
export declare function interactiveStatePrefetchEnabled(): boolean;
|
|
10
|
+
/** Test/tuning hook for the trigger debounce. */
|
|
11
|
+
export declare function setInteractiveStatePrefetchDebounceMs(ms: number): void;
|
|
12
|
+
/**
|
|
13
|
+
* The page identity the store's applyInteractiveState would produce for one
|
|
14
|
+
* exclusive-axis option, computed WITHOUT mutating the page.
|
|
15
|
+
*
|
|
16
|
+
* DUPLICATED DERIVATION — store.ts applyInteractiveState is the source of
|
|
17
|
+
* truth. Extracting the shared pure core would touch that method's body,
|
|
18
|
+
* which is deliberately left alone; the agreement test in
|
|
19
|
+
* variantPrefetch.test.mjs pins the two implementations to each other, so a
|
|
20
|
+
* change to either fails loudly. Only the single-option branch is mirrored:
|
|
21
|
+
* combining (`multiple`) axes hold sets and are out of prefetch's scope.
|
|
22
|
+
*
|
|
23
|
+
* Returns null when selecting the option asks supply for nothing new — the
|
|
24
|
+
* base option, or the option this frame's capture already shows — because
|
|
25
|
+
* the store would reset the frame to its base recipe.
|
|
26
|
+
*/
|
|
27
|
+
export declare function deriveInteractiveVariantIdentity(page: PageModel, def: InteractiveStateDef, option: InteractiveStateOption): PageModel | null;
|
|
28
|
+
export interface InteractiveVariantPrefetchCandidate {
|
|
29
|
+
axisId: string;
|
|
30
|
+
optionId: string;
|
|
31
|
+
/** The recipe key the option's real selection would publish under. */
|
|
32
|
+
cacheKey: string;
|
|
33
|
+
/** Hypothetical identity handed to prefetchPageInteractiveVariant. */
|
|
34
|
+
variantPage: PageModel;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The unproduced variants of one frame, in declaration order, capped at
|
|
38
|
+
* `limit`. An option is skipped when it needs no work (base or captured),
|
|
39
|
+
* its future key already has a snapshot, or the runner already tracks its
|
|
40
|
+
* variant id (a prefetch or a real request is on it). Only declared
|
|
41
|
+
* exclusive axes participate: combining axes hold sets with no single next
|
|
42
|
+
* click to predict, and automatic pseudo axes are synthesized per element —
|
|
43
|
+
* both stay on the on-demand path.
|
|
44
|
+
*/
|
|
45
|
+
export declare function planInteractiveStatePrefetch(page: PageModel, previewRevision: string, limit?: number): InteractiveVariantPrefetchCandidate[];
|
|
46
|
+
/** Diagnostic counters for the headless debug surface and tests. */
|
|
47
|
+
export declare const __variantPrefetchDebug: {
|
|
48
|
+
requested: number;
|
|
49
|
+
rounds: number;
|
|
50
|
+
queued: number;
|
|
51
|
+
exhausted: number;
|
|
52
|
+
unavailable: number;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Starts a prefetch round for one frame immediately (no debounce): up to
|
|
56
|
+
* MAX_PREFETCH_WALKS_PER_ROUND idle-time walks, one at a time. Replaces any
|
|
57
|
+
* round already in progress — the newest selection is the best prediction.
|
|
58
|
+
*/
|
|
59
|
+
export declare function startInteractiveStatePrefetch(page: PageModel, previewRevision: string): void;
|
|
60
|
+
/** Drops the scheduled trigger and the in-progress round, if any. */
|
|
61
|
+
export declare function cancelScheduledInteractiveStatePrefetch(): void;
|
|
62
|
+
/**
|
|
63
|
+
* Debounced trigger for the panel: called when a frame's axes become
|
|
64
|
+
* visible. The debounce keeps a designer stepping across frames from
|
|
65
|
+
* starting a round per keystroke of selection. Returns a cancel for the
|
|
66
|
+
* effect cleanup — cancelling stops the round, so speculation only runs
|
|
67
|
+
* while the frame is actually the selected one.
|
|
68
|
+
*/
|
|
69
|
+
export declare function requestInteractiveStatePrefetch(page: PageModel, previewRevision: string): () => void;
|
package/dist-lib/types/lib.d.ts
CHANGED
|
@@ -92,7 +92,7 @@ export type { ConditionCoverageRoute, ConditionCoverageScreen, ConformanceScreen
|
|
|
92
92
|
export { clearPreviewEnvironmentControlValues, getPreviewEnvironmentControlValue, getPreviewEnvironmentControls, getPreviewEnvironmentOverride, setPreviewEnvironmentControlValue, setPreviewEnvironmentControls, subscribePreviewEnvironmentControls, } from './editor/previewEnvironmentControls';
|
|
93
93
|
export type { PreviewEnvironmentControlDef, PreviewEnvironmentControlOptionDef, } from './editor/previewEnvironmentControls';
|
|
94
94
|
export { createDesignImportController, createDesignScreenCollection, DESIGN_IMPORT_KINDS, expandDesignScreenCasesViewports, expandDesignScreenCollectionViewports, mergeScreenPresets, } from './editor/designImport';
|
|
95
|
-
export type { CreateDesignImportOptions, DesignBehaviorScenario, DesignImportAsset, DesignImportCollection, DesignImportController, DesignImportCoverage, DesignImportCoverageItem, DesignImportInitialPage, DesignImportKind, DesignImportManifest, DesignImportPage, DesignFrameHeightMode, DesignFrameSizeMode, DesignScenarioCoverage, DesignScenarioSource, DesignScreenCase, DesignScreenAssertion, DesignScreenAssertionReport, DesignScreenAssertionVisibility, DesignScreenAttributeAssertion, DesignScreenCaptureFailure, DesignScreenCaptureFailureCode, DesignScreenCaptureFailureStage, DesignScreenCaptureDefaults, DesignScreenCaptureRequirements, DesignScreenCaptureReport, DesignScreenCaptureSpec, DesignScreenCollectionResult, DesignScreenInteraction, DesignScreenInteractionAction, DesignScreenInteractionRunOptions, DesignScreenInteractionReport, DesignScreenPreset, DesignScreenPresetContext, DesignScreenPresetExecutionResult, DesignScreenPresetExecutor, DesignScreenPresetRequest, DesignScreenViewport, DesignScreenViewportExpansionOptions, DesignScreenViewportPagePatch, DesignSerializable, StoryboardEnvironment, StoryboardMediaDevice, StoryboardMediaFailure, StoryboardNetwork, StoryboardPermissionState, StoryboardRequestCondition, StoryboardRequestOutcome, } from './editor/designImport';
|
|
95
|
+
export type { CreateDesignImportOptions, DesignBehaviorScenario, DesignImportAsset, DesignImportCollection, DesignImportController, DesignImportCoverage, DesignImportCoverageItem, DesignImportInitialPage, DesignImportKind, DesignImportManifest, DesignImportPage, DesignFrameHeightMode, DesignFrameSizeMode, DesignScenarioCoverage, DesignScenarioSource, DesignScreenCase, DesignScreenAssertion, DesignScreenAssertionReport, DesignScreenAssertionVisibility, DesignScreenAttributeAssertion, DesignScreenCaptureFailure, DesignScreenCaptureFailureCode, DesignScreenCaptureFailureStage, DesignScreenCaptureDefaults, DesignScreenCaptureRequirements, DesignScreenCaptureReport, DesignScreenCaptureSpec, DesignScreenCollectionResult, DesignScreenDesiredState, DesignScreenDesiredStateExecutor, DesignScreenDesiredStateRequest, DesignScreenInteraction, DesignScreenInteractionAction, DesignScreenInteractionRunOptions, DesignScreenInteractionReport, DesignScreenPreset, DesignScreenPresetContext, DesignScreenPresetExecutionResult, DesignScreenPresetExecutor, DesignScreenPresetRequest, DesignScreenViewport, DesignScreenViewportExpansionOptions, DesignScreenViewportPagePatch, DesignSerializable, StoryboardEnvironment, StoryboardMediaDevice, StoryboardMediaFailure, StoryboardNetwork, StoryboardPermissionState, StoryboardRequestCondition, StoryboardRequestOutcome, } from './editor/designImport';
|
|
96
96
|
export type { DomImportDiagnostic, DomImportMetrics, DomImportOptions, DomImportResult, } from './editor/domImport';
|
|
97
97
|
export type { SharedSourceEditScope, SharedSourceFrameSummary, SharedSourceImpact, } from './editor/sharedSource';
|
|
98
98
|
export type { NodeJSON } from './editor/store';
|
|
@@ -182,6 +182,9 @@ export declare const __debug: {
|
|
|
182
182
|
noPath: number;
|
|
183
183
|
resolvedNull: number;
|
|
184
184
|
queued: number;
|
|
185
|
+
reparkQueued: number;
|
|
186
|
+
reparkAlreadyParked: number;
|
|
187
|
+
reparkNoExtraSteps: number;
|
|
185
188
|
};
|
|
186
189
|
states: Record<string, unknown>;
|
|
187
190
|
};
|
|
@@ -201,8 +204,71 @@ export declare const __debug: {
|
|
|
201
204
|
reuses: number;
|
|
202
205
|
parks: number;
|
|
203
206
|
evictions: number;
|
|
207
|
+
refreshes: number;
|
|
204
208
|
};
|
|
205
209
|
}>;
|
|
210
|
+
/**
|
|
211
|
+
* State-switch instrumentation: phase timings of recent flow-session walks
|
|
212
|
+
* (boot/replay/settle/stability/serialize/publish, per runner label, warm
|
|
213
|
+
* reuse vs cold boot) plus route-preview producer-gate counters. JSON-safe.
|
|
214
|
+
*/
|
|
215
|
+
stateSwitchMetrics(): Promise<{
|
|
216
|
+
walks: {
|
|
217
|
+
byRunner: {
|
|
218
|
+
[k: string]: {
|
|
219
|
+
count: number;
|
|
220
|
+
completed: number;
|
|
221
|
+
coldBoots: number;
|
|
222
|
+
warmReuses: number;
|
|
223
|
+
stabilityTimeouts: number;
|
|
224
|
+
phases: Record<import("./editor/stateSwitchMetrics").StateSwitchPhase, import("./editor/stateSwitchMetrics").PhaseAggregate>;
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
recent: {
|
|
228
|
+
totals: {
|
|
229
|
+
replayMs: number;
|
|
230
|
+
settleMs: number;
|
|
231
|
+
stabilityMs: number;
|
|
232
|
+
serializeMs: number;
|
|
233
|
+
publishMs: number;
|
|
234
|
+
};
|
|
235
|
+
waypoints: {
|
|
236
|
+
screenId: string;
|
|
237
|
+
replayMs: number | null;
|
|
238
|
+
settleMs: number | null;
|
|
239
|
+
stabilityMs: number | null;
|
|
240
|
+
stabilityTimedOut: boolean;
|
|
241
|
+
serializeMs: number | null;
|
|
242
|
+
publishMs: number | null;
|
|
243
|
+
}[];
|
|
244
|
+
runner: string;
|
|
245
|
+
pathId: string;
|
|
246
|
+
reused: boolean;
|
|
247
|
+
completed: boolean;
|
|
248
|
+
startedAtEpochMs: number;
|
|
249
|
+
totalMs: number;
|
|
250
|
+
bootMs: number | null;
|
|
251
|
+
}[];
|
|
252
|
+
count: number;
|
|
253
|
+
completed: number;
|
|
254
|
+
coldBoots: number;
|
|
255
|
+
warmReuses: number;
|
|
256
|
+
stabilityTimeouts: number;
|
|
257
|
+
phases: Record<import("./editor/stateSwitchMetrics").StateSwitchPhase, import("./editor/stateSwitchMetrics").PhaseAggregate>;
|
|
258
|
+
};
|
|
259
|
+
routePreviewProducer: {
|
|
260
|
+
evaluations: number;
|
|
261
|
+
allowed: number;
|
|
262
|
+
suppressed: number;
|
|
263
|
+
byTrigger: {
|
|
264
|
+
endpointNotConfigured: number;
|
|
265
|
+
detailActive: number;
|
|
266
|
+
previewIntended: number;
|
|
267
|
+
};
|
|
268
|
+
};
|
|
269
|
+
}>;
|
|
270
|
+
/** Clears the state-switch metrics between headless audit scenarios. */
|
|
271
|
+
resetStateSwitchMetrics(): Promise<void>;
|
|
206
272
|
};
|
|
207
273
|
/**
|
|
208
274
|
* Called from the actual button handler of the host screen fixture — Switches to the name matching page (common in editor and preview).
|
|
@@ -6,7 +6,7 @@ how the variation is reproduced, not from how different its pixels look.
|
|
|
6
6
|
| Placement | Use when | Pygmalion declaration |
|
|
7
7
|
| --- | --- | --- |
|
|
8
8
|
| Frame | The variation is an independent task, journey checkpoint, structural composition, overlay context, or supported viewport that reviewers must navigate to directly. | `DesignScreenCase` |
|
|
9
|
-
| Interaction state | The same booted screen
|
|
9
|
+
| Interaction state | The same booted screen has a deterministic, reversible visual endpoint, reached either directly or through a local gesture. | `ScreenStateAxisDef` with `kind: 'interaction'` and `desiredState` or `steps` |
|
|
10
10
|
| Condition state | The same screen needs external data, network, storage, permission, media, or timing conditions before it boots. | `ScreenStateAxisDef` with `kind: 'condition'` |
|
|
11
11
|
| Editable parameter | The variation only samples text length, row count, content, or another freely editable value and has no distinct product endpoint. | Edit mode, component props, list controls, viewport controls, or QA data |
|
|
12
12
|
| Motion preview | The same screen continuously changes through CSS or Web Animations without becoming a new product state. | Automatically discovered **Motion** controls |
|
|
@@ -41,9 +41,10 @@ interaction states. They must not become duplicate frames.
|
|
|
41
41
|
|
|
42
42
|
The W and H controls in the frame inspector change the frame viewport, not the
|
|
43
43
|
canvas camera. A committed size is part of the frame fingerprint and its exact
|
|
44
|
-
capture recipe. The request carries the route, width, height, conditions,
|
|
45
|
-
interactions that produced that fingerprint
|
|
46
|
-
under that requested recipe instead of the authored catalog
|
|
44
|
+
capture recipe. The request carries the route, width, height, conditions,
|
|
45
|
+
desired state, and interactions that produced that fingerprint. A returned
|
|
46
|
+
artifact is seeded under that requested recipe instead of the authored catalog
|
|
47
|
+
key.
|
|
47
48
|
|
|
48
49
|
This distinction is observable: resizing a frame may change responsive layout,
|
|
49
50
|
while changing canvas zoom only changes how large the same frame appears in the
|
|
@@ -73,6 +74,12 @@ axis targets the same element and pseudo action, Pygmalion suppresses that
|
|
|
73
74
|
automatic duplicate. Click-driven toggles and other application states remain
|
|
74
75
|
declared axes unless they already have their own review frame.
|
|
75
76
|
|
|
77
|
+
In view-only mode, `requires.testId` is matched against the frozen preview's
|
|
78
|
+
lightweight test-id index as well as an imported layer tree. This keeps right
|
|
79
|
+
panel state controls available without turning selection into a layer import or
|
|
80
|
+
live boot, and still hides an axis from screens whose captured DOM lacks its
|
|
81
|
+
target.
|
|
82
|
+
|
|
76
83
|
## Automatic motion coverage
|
|
77
84
|
|
|
78
85
|
Pygmalion discovers visible CSS animation owners, animated pseudo elements,
|
|
@@ -140,6 +147,11 @@ export const screenStateAxes: readonly ScreenStateAxisDef[] = [
|
|
|
140
147
|
},
|
|
141
148
|
],
|
|
142
149
|
},
|
|
150
|
+
{
|
|
151
|
+
id: 'collapsed',
|
|
152
|
+
label: 'Collapsed',
|
|
153
|
+
desiredState: 'collapsed',
|
|
154
|
+
},
|
|
143
155
|
],
|
|
144
156
|
},
|
|
145
157
|
{
|
|
@@ -167,11 +179,48 @@ export const screenStateAxes: readonly ScreenStateAxisDef[] = [
|
|
|
167
179
|
```
|
|
168
180
|
|
|
169
181
|
An interaction option cannot declare `environment`. Every non-base condition
|
|
170
|
-
option must declare one.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
state
|
|
182
|
+
option must declare one. A direct `desiredState` value cannot be combined with
|
|
183
|
+
steps or an environment on the same option. An interaction option ending in
|
|
184
|
+
`fill` is rejected unless a later targeted `wait` proves that the input reached
|
|
185
|
+
a distinct visual result. This keeps empty strings and maximum-length samples
|
|
186
|
+
out of the state panel while still allowing a search input to reproduce a real
|
|
187
|
+
empty-result state.
|
|
188
|
+
|
|
189
|
+
## Direct desired state
|
|
190
|
+
|
|
191
|
+
Use direct desired state for a locally controlled endpoint whose final visual
|
|
192
|
+
state matters more than the gestures used to reach it. Pygmalion builds a
|
|
193
|
+
complete map from selected axes, such as `{ sidebar: 'collapsed' }`, and calls
|
|
194
|
+
the host's `executeDesiredState` adapter after mount and before any remaining
|
|
195
|
+
interaction steps. The map participates in capture and preview identity.
|
|
196
|
+
|
|
197
|
+
The adapter must replace its whole preview state, not merge it. Pygmalion calls
|
|
198
|
+
it with `{}` as well, so a warm iframe reused for another frame cannot retain a
|
|
199
|
+
previous override. A typical React host exposes the map through an external
|
|
200
|
+
store and lets each component read only its own axis. The adapter is preview
|
|
201
|
+
infrastructure: it must not dispatch the application's production store or
|
|
202
|
+
write persistent product state. When a reviewer uses the real control, the
|
|
203
|
+
component should release its preview override and continue from the currently
|
|
204
|
+
displayed value.
|
|
205
|
+
|
|
206
|
+
The right-panel selection starts delivery in the same input task. Once one
|
|
207
|
+
option has a live document, sibling direct-state options share that document:
|
|
208
|
+
Pygmalion skips boot, route entry, gesture replay, assertions, and geometry
|
|
209
|
+
settling, then sends the replacement map to the host adapter. The React commit
|
|
210
|
+
is the presentation boundary; snapshot serialization may continue afterward
|
|
211
|
+
without blocking the visible state. A live direct-state document is pinned
|
|
212
|
+
against normal warm-pool eviction and cannot be claimed through gesture-prefix
|
|
213
|
+
matching, because those signatures do not describe host-owned state.
|
|
214
|
+
Sibling delivery calls the live adapter directly rather than joining the replay
|
|
215
|
+
runner's FIFO. Frames that expose a direct-state axis also reserve their
|
|
216
|
+
activation-warmed document from unrelated gesture prefetch and align it
|
|
217
|
+
invisibly behind the frozen frame. The first selection can therefore reveal the
|
|
218
|
+
already-positioned React document after its adapter commit; speculative work
|
|
219
|
+
cannot reintroduce a queue between the panel click and that commit.
|
|
220
|
+
|
|
221
|
+
Keep gesture steps when the transition itself is under review, when effects
|
|
222
|
+
outside local render state establish the endpoint, or when the host has no safe
|
|
223
|
+
preview-only adapter for that state.
|
|
175
224
|
|
|
176
225
|
## Scroll positions
|
|
177
226
|
|
package/node/dev-mirror.mjs
CHANGED
|
@@ -37,6 +37,47 @@ export function resolveDevMirrorInventoryOutputRoot(inventory, mirrorAppRoot) {
|
|
|
37
37
|
return path.resolve(inventory?.outputRoot ?? mirrorAppRoot);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** A running preview belongs to one immutable app root for its whole process. */
|
|
41
|
+
export function devPreviewNeedsRestart({
|
|
42
|
+
force = false,
|
|
43
|
+
running = false,
|
|
44
|
+
activeAppRoot = null,
|
|
45
|
+
nextAppRoot,
|
|
46
|
+
}) {
|
|
47
|
+
if (force) return true;
|
|
48
|
+
if (!running) return false;
|
|
49
|
+
if (typeof activeAppRoot !== 'string' || !activeAppRoot.trim()) return true;
|
|
50
|
+
return path.resolve(activeAppRoot) !== path.resolve(nextAppRoot);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A commit is not ready when the proxy child still serves another ref's root.
|
|
55
|
+
* Returning drifted makes the ordinary boot gate request the same resync that
|
|
56
|
+
* repairs a checkout whose HEAD moved underneath the server.
|
|
57
|
+
*/
|
|
58
|
+
export function devMirrorPreviewDriftStatus(
|
|
59
|
+
status,
|
|
60
|
+
{ running = false, activeAppRoot = null, expectedAppRoot },
|
|
61
|
+
) {
|
|
62
|
+
if (status?.state !== 'ready' || typeof expectedAppRoot !== 'string') {
|
|
63
|
+
return status;
|
|
64
|
+
}
|
|
65
|
+
const actual =
|
|
66
|
+
running && typeof activeAppRoot === 'string' && activeAppRoot.trim()
|
|
67
|
+
? path.resolve(activeAppRoot)
|
|
68
|
+
: null;
|
|
69
|
+
const expected = path.resolve(expectedAppRoot);
|
|
70
|
+
if (actual === expected) return status;
|
|
71
|
+
return {
|
|
72
|
+
...status,
|
|
73
|
+
state: 'drifted',
|
|
74
|
+
runtimeDrift: { expectedAppRoot: expected, actualAppRoot: actual },
|
|
75
|
+
error: actual
|
|
76
|
+
? `dev screen runtime drifted: expected ${expected}, serving ${actual}`
|
|
77
|
+
: `dev screen runtime stopped: expected ${expected}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
40
81
|
/**
|
|
41
82
|
* Directory-safe form of a source ref, used to give each ref its own checkout.
|
|
42
83
|
*/
|
|
@@ -877,6 +918,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
877
918
|
let runtimeRequested = false;
|
|
878
919
|
let previewChild = null;
|
|
879
920
|
let previewPort = null;
|
|
921
|
+
let previewAppRoot = null;
|
|
880
922
|
let syncPromise = null;
|
|
881
923
|
let sharedLockPathPromise = null;
|
|
882
924
|
// Assigned once the server is configured. A capture asks for the checkout
|
|
@@ -962,6 +1004,18 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
962
1004
|
*/
|
|
963
1005
|
const verifiedStatus = async () => {
|
|
964
1006
|
if (status.state !== 'ready' || !status.commit) return status;
|
|
1007
|
+
const runtimeStatus = devMirrorPreviewDriftStatus(status, {
|
|
1008
|
+
running:
|
|
1009
|
+
previewChild != null &&
|
|
1010
|
+
previewChild.exitCode == null &&
|
|
1011
|
+
previewPort != null,
|
|
1012
|
+
activeAppRoot: previewAppRoot,
|
|
1013
|
+
expectedAppRoot: mirrorAppRoot,
|
|
1014
|
+
});
|
|
1015
|
+
if (runtimeStatus !== status) {
|
|
1016
|
+
status = runtimeStatus;
|
|
1017
|
+
return status;
|
|
1018
|
+
}
|
|
965
1019
|
if (Date.now() - lastVerifiedAt < STATUS_VERIFY_TTL_MS) return status;
|
|
966
1020
|
lastVerifiedAt = Date.now();
|
|
967
1021
|
const actual = await git(mirrorRoot, 'rev-parse', 'HEAD').catch(() => null);
|
|
@@ -973,6 +1027,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
973
1027
|
const child = previewChild;
|
|
974
1028
|
previewChild = null;
|
|
975
1029
|
previewPort = null;
|
|
1030
|
+
previewAppRoot = null;
|
|
976
1031
|
if (!child || child.exitCode != null) return;
|
|
977
1032
|
child.kill('SIGTERM');
|
|
978
1033
|
};
|
|
@@ -1052,15 +1107,29 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1052
1107
|
await run(command, args, { cwd: path.resolve(inventory.cwd ?? editorRoot) });
|
|
1053
1108
|
};
|
|
1054
1109
|
|
|
1055
|
-
const startPreview = async (
|
|
1110
|
+
const startPreview = async (forceRestart, sourceIdentity = ref) => {
|
|
1111
|
+
const running =
|
|
1112
|
+
previewChild != null &&
|
|
1113
|
+
previewChild.exitCode == null &&
|
|
1114
|
+
previewPort != null;
|
|
1115
|
+
const restart = devPreviewNeedsRestart({
|
|
1116
|
+
force: forceRestart,
|
|
1117
|
+
running,
|
|
1118
|
+
activeAppRoot: previewAppRoot,
|
|
1119
|
+
nextAppRoot: mirrorAppRoot,
|
|
1120
|
+
});
|
|
1056
1121
|
if (restart) await stopPreview();
|
|
1057
1122
|
if (previewChild && previewChild.exitCode == null && previewPort != null) return;
|
|
1058
1123
|
|
|
1124
|
+
// Keep the process identity local. `mirrorAppRoot` is mutable and can point
|
|
1125
|
+
// at another ref by the time readiness or the exit callback runs.
|
|
1126
|
+
const appRoot = mirrorAppRoot;
|
|
1127
|
+
const appViteConfig = viteConfig;
|
|
1059
1128
|
previewPort = await pickPort(preferredPreviewPort);
|
|
1060
1129
|
const viteBin = path.resolve(
|
|
1061
1130
|
options.viteBin ??
|
|
1062
1131
|
path.join(
|
|
1063
|
-
|
|
1132
|
+
appRoot,
|
|
1064
1133
|
dependencies.modulesDirectory ?? 'node_modules',
|
|
1065
1134
|
'vite',
|
|
1066
1135
|
'bin',
|
|
@@ -1081,32 +1150,34 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1081
1150
|
'--strictPort',
|
|
1082
1151
|
],
|
|
1083
1152
|
{
|
|
1084
|
-
cwd:
|
|
1153
|
+
cwd: appRoot,
|
|
1085
1154
|
env: {
|
|
1086
1155
|
...process.env,
|
|
1087
1156
|
PYGMALION_PREVIEW_MODE: '1',
|
|
1088
1157
|
// The child outlives a killed parent otherwise — it watches this pid.
|
|
1089
1158
|
PYGMALION_PREVIEW_PARENT_PID: String(process.pid),
|
|
1090
|
-
PYGMALION_APP_ROOT:
|
|
1091
|
-
PYGMALION_VITE_CONFIG:
|
|
1159
|
+
PYGMALION_APP_ROOT: appRoot,
|
|
1160
|
+
PYGMALION_VITE_CONFIG: appViteConfig,
|
|
1092
1161
|
PYGMALION_PREVIEW_BASE: `${prefix}/`,
|
|
1093
1162
|
PYGMALION_VITE_CACHE_DIR: resolvePygmalionPreviewViteCacheDir({
|
|
1094
|
-
appRoot
|
|
1163
|
+
appRoot,
|
|
1095
1164
|
instance: `mirror:${sourceIdentity}:${prefix}`,
|
|
1096
1165
|
modulesDirectory: dependencies.modulesDirectory,
|
|
1097
1166
|
}),
|
|
1098
1167
|
// Legacy names keep older preview configs working.
|
|
1099
|
-
PYGMALION_DEV_FRONTEND_ROOT:
|
|
1168
|
+
PYGMALION_DEV_FRONTEND_ROOT: appRoot,
|
|
1100
1169
|
PYGMALION_DEV_BASE: `${prefix}/`,
|
|
1101
1170
|
},
|
|
1102
1171
|
stdio: ['ignore', 'inherit', 'inherit'],
|
|
1103
1172
|
},
|
|
1104
1173
|
);
|
|
1105
1174
|
previewChild = child;
|
|
1175
|
+
previewAppRoot = appRoot;
|
|
1106
1176
|
child.once('exit', (code) => {
|
|
1107
1177
|
if (previewChild !== child) return;
|
|
1108
1178
|
previewChild = null;
|
|
1109
1179
|
previewPort = null;
|
|
1180
|
+
previewAppRoot = null;
|
|
1110
1181
|
if (status.state === 'ready' && code !== 0) {
|
|
1111
1182
|
status = {
|
|
1112
1183
|
...status,
|
|
@@ -1115,7 +1186,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1115
1186
|
};
|
|
1116
1187
|
}
|
|
1117
1188
|
});
|
|
1118
|
-
await waitUntilReady(previewPort,
|
|
1189
|
+
await waitUntilReady(previewPort, appRoot, prefix);
|
|
1119
1190
|
};
|
|
1120
1191
|
|
|
1121
1192
|
const syncMirror = async (nextRef) => {
|
|
@@ -1201,6 +1272,7 @@ export function pygmalionDevMirrorPlugin(options) {
|
|
|
1201
1272
|
pygmalion: {
|
|
1202
1273
|
acquireLease: (label) => acquireDevMirrorLease({ repoRoot, mirrorRoot, label }),
|
|
1203
1274
|
activeMirrorRoot: () => mirrorRoot,
|
|
1275
|
+
activeAppRoot: () => mirrorAppRoot,
|
|
1204
1276
|
// A capture needs the checkout as much as a live frame does, but it asks
|
|
1205
1277
|
// at another plugin's endpoint. Without these two the demand never
|
|
1206
1278
|
// reached the starter: the frame had no origin to request, so nothing
|
|
@@ -170,6 +170,16 @@ function requestedFrames(params) {
|
|
|
170
170
|
if (typeof fingerprint !== 'string' || !fingerprint.trim()) return null;
|
|
171
171
|
if (fingerprint.length > MAX_IDENTITY_LENGTH) return null;
|
|
172
172
|
}
|
|
173
|
+
const recipeFingerprint = item.recipeFingerprint;
|
|
174
|
+
if (recipeFingerprint !== undefined) {
|
|
175
|
+
if (
|
|
176
|
+
typeof recipeFingerprint !== 'string' ||
|
|
177
|
+
!recipeFingerprint.trim() ||
|
|
178
|
+
recipeFingerprint.length > MAX_IDENTITY_LENGTH
|
|
179
|
+
) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
173
183
|
// The recipe the fingerprint stands for. A fingerprint is a hash — a
|
|
174
184
|
// generator handed one alone can only re-capture what the host declared for
|
|
175
185
|
// that id, which is a different screen wearing the requested name. Carried
|
|
@@ -184,6 +194,7 @@ function requestedFrames(params) {
|
|
|
184
194
|
wanted.push({
|
|
185
195
|
id,
|
|
186
196
|
...(fingerprint === undefined ? {} : { fingerprint }),
|
|
197
|
+
...(recipeFingerprint === undefined ? {} : { recipeFingerprint }),
|
|
187
198
|
...(recipe === undefined ? {} : { recipe }),
|
|
188
199
|
});
|
|
189
200
|
}
|
|
@@ -256,6 +267,7 @@ function exactArtifact(artifact, namespace, sourceRevision) {
|
|
|
256
267
|
*/
|
|
257
268
|
export function pygmalionPreviewArtifactPlugin({
|
|
258
269
|
root = process.cwd(),
|
|
270
|
+
sourceRoot,
|
|
259
271
|
artifactFile = DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
|
|
260
272
|
endpoint = PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
|
|
261
273
|
disabled = () => false,
|
|
@@ -305,7 +317,7 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
305
317
|
artifactFile: resolvedArtifact,
|
|
306
318
|
// With the source root, a published frame records the files it actually
|
|
307
319
|
// rendered, so freshness stops depending on which revision captured it.
|
|
308
|
-
sourceRoot: resolvedRoot,
|
|
320
|
+
sourceRoot: sourceRoot ?? resolvedRoot,
|
|
309
321
|
...(artifactStoreMaxBytes == null
|
|
310
322
|
? {}
|
|
311
323
|
: { maxBytes: artifactStoreMaxBytes }),
|
|
@@ -644,6 +656,7 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
644
656
|
await artifactStore.publishArtifact(generated, {
|
|
645
657
|
sourceRevision,
|
|
646
658
|
recordRevision: false,
|
|
659
|
+
frameRequests: remaining,
|
|
647
660
|
});
|
|
648
661
|
// Retention is allowed to decline a newly generated object when older
|
|
649
662
|
// entries have earned higher read recency. That cache decision must not
|
|
@@ -75,6 +75,8 @@ function validManifestEntry(entry, kind) {
|
|
|
75
75
|
typeof entry.id === 'string' &&
|
|
76
76
|
entry.id.length > 0 &&
|
|
77
77
|
(entry.fingerprint == null || typeof entry.fingerprint === 'string') &&
|
|
78
|
+
(entry.recipeFingerprint == null ||
|
|
79
|
+
typeof entry.recipeFingerprint === 'string') &&
|
|
78
80
|
(entry.sourceRevision == null || typeof entry.sourceRevision === 'string')
|
|
79
81
|
);
|
|
80
82
|
}
|
|
@@ -269,6 +271,13 @@ export function createPreviewArtifactStore({
|
|
|
269
271
|
}
|
|
270
272
|
const legacyFile = path.resolve(artifactFile);
|
|
271
273
|
const root = path.resolve(storeDirectory);
|
|
274
|
+
const currentSourceRoot = () => {
|
|
275
|
+
const candidate =
|
|
276
|
+
typeof sourceRoot === 'function' ? sourceRoot() : sourceRoot;
|
|
277
|
+
return typeof candidate === 'string' && candidate
|
|
278
|
+
? path.resolve(candidate)
|
|
279
|
+
: undefined;
|
|
280
|
+
};
|
|
272
281
|
let legacyCache = null;
|
|
273
282
|
const objectCache = new Map();
|
|
274
283
|
let objectCacheBytes = 0;
|
|
@@ -451,7 +460,10 @@ export function createPreviewArtifactStore({
|
|
|
451
460
|
const classifyFrameEntry = async (entry) => {
|
|
452
461
|
const observed = normalizeObservedDependencies(entry.sourceFiles);
|
|
453
462
|
if (!observed) return 'unknown';
|
|
454
|
-
return (await observedDependenciesUnchanged({
|
|
463
|
+
return (await observedDependenciesUnchanged({
|
|
464
|
+
recorded: observed,
|
|
465
|
+
sourceRoot: currentSourceRoot(),
|
|
466
|
+
}))
|
|
455
467
|
? 'valid'
|
|
456
468
|
: 'invalid';
|
|
457
469
|
};
|
|
@@ -626,6 +638,7 @@ export function createPreviewArtifactStore({
|
|
|
626
638
|
recordRevision = true,
|
|
627
639
|
sourceRevision = artifact?.sourceRevision,
|
|
628
640
|
materializeLegacy = true,
|
|
641
|
+
frameRequests = [],
|
|
629
642
|
} = {},
|
|
630
643
|
) => {
|
|
631
644
|
const validation = validateRoutePreviewArtifactBundle(artifact);
|
|
@@ -680,6 +693,9 @@ export function createPreviewArtifactStore({
|
|
|
680
693
|
}
|
|
681
694
|
}
|
|
682
695
|
if (artifact.version === 3) {
|
|
696
|
+
const requestsById = new Map(
|
|
697
|
+
frameRequests.map((request) => [request.id, request]),
|
|
698
|
+
);
|
|
683
699
|
let frames = manifest.frames;
|
|
684
700
|
for (const [id, frame] of Object.entries(artifact.frames)) {
|
|
685
701
|
const selected = selectRoutePreviewArtifactFrames(artifact, [{ id }]);
|
|
@@ -690,7 +706,7 @@ export function createPreviewArtifactStore({
|
|
|
690
706
|
normalizeObservedDependencies(frame.sourceFiles) ??
|
|
691
707
|
(await recordObservedDependencies({
|
|
692
708
|
snapshot: selected.bundle?.frames?.[id]?.snapshot ?? frame.snapshot,
|
|
693
|
-
sourceRoot,
|
|
709
|
+
sourceRoot: currentSourceRoot(),
|
|
694
710
|
...(observedAlwaysInclude
|
|
695
711
|
? { alwaysInclude: observedAlwaysInclude }
|
|
696
712
|
: {}),
|
|
@@ -699,6 +715,12 @@ export function createPreviewArtifactStore({
|
|
|
699
715
|
id,
|
|
700
716
|
fingerprint:
|
|
701
717
|
typeof frame.fingerprint === 'string' ? frame.fingerprint : null,
|
|
718
|
+
...(typeof requestsById.get(id)?.recipeFingerprint === 'string'
|
|
719
|
+
? {
|
|
720
|
+
recipeFingerprint:
|
|
721
|
+
requestsById.get(id).recipeFingerprint,
|
|
722
|
+
}
|
|
723
|
+
: {}),
|
|
702
724
|
sourceRevision: sourceRevision ?? null,
|
|
703
725
|
object,
|
|
704
726
|
generation,
|
|
@@ -893,9 +915,23 @@ export function createPreviewArtifactStore({
|
|
|
893
915
|
if (request.fingerprint != null && entry.fingerprint === request.fingerprint) {
|
|
894
916
|
return true;
|
|
895
917
|
}
|
|
918
|
+
// Source hashes cannot prove that viewport, environment, or interaction
|
|
919
|
+
// inputs stayed the same. They may bridge a changed source fingerprint only
|
|
920
|
+
// when the independently recorded capture recipe still matches.
|
|
921
|
+
if (
|
|
922
|
+
request.fingerprint != null &&
|
|
923
|
+
(request.recipeFingerprint == null ||
|
|
924
|
+
entry.recipeFingerprint == null ||
|
|
925
|
+
entry.recipeFingerprint !== request.recipeFingerprint)
|
|
926
|
+
) {
|
|
927
|
+
return false;
|
|
928
|
+
}
|
|
896
929
|
const observed = normalizeObservedDependencies(entry.sourceFiles);
|
|
897
930
|
if (observed) {
|
|
898
|
-
return observedDependenciesUnchanged({
|
|
931
|
+
return observedDependenciesUnchanged({
|
|
932
|
+
recorded: observed,
|
|
933
|
+
sourceRoot: currentSourceRoot(),
|
|
934
|
+
});
|
|
899
935
|
}
|
|
900
936
|
// Nothing content-based to go on: fall back to the old rule so a frame is
|
|
901
937
|
// never treated as fresher than it was before.
|
|
@@ -752,6 +752,42 @@ async function executeStoryboardPreset(page, screenCase, route) {
|
|
|
752
752
|
}, request);
|
|
753
753
|
}
|
|
754
754
|
|
|
755
|
+
/**
|
|
756
|
+
* Replaces host-owned post-mount UI state on the running application page.
|
|
757
|
+
* Empty state is still delivered when an adapter exists, because a warm page
|
|
758
|
+
* may carry the previous case's override. Only a non-empty request requires
|
|
759
|
+
* the host hook, preserving compatibility for hosts that do not use this path.
|
|
760
|
+
*/
|
|
761
|
+
export async function executeStoryboardDesiredState(page, screenCase, route) {
|
|
762
|
+
const state =
|
|
763
|
+
screenCase.desiredState && typeof screenCase.desiredState === 'object'
|
|
764
|
+
? screenCase.desiredState
|
|
765
|
+
: {};
|
|
766
|
+
const request = {
|
|
767
|
+
pageId: screenCase.id,
|
|
768
|
+
route,
|
|
769
|
+
state,
|
|
770
|
+
};
|
|
771
|
+
const required = Object.keys(state).length > 0;
|
|
772
|
+
await page.evaluate(async ({ payload, required }) => {
|
|
773
|
+
const apply = window.__PYGMALION_APPLY_DESIRED_STATE__;
|
|
774
|
+
if (typeof apply !== 'function') {
|
|
775
|
+
if (required) {
|
|
776
|
+
throw new Error(
|
|
777
|
+
'There is a screen desired state, but the host adapter is not installed.',
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const result = await apply(payload);
|
|
783
|
+
if (result && result.complete === false) {
|
|
784
|
+
throw new Error(
|
|
785
|
+
result.message ?? 'The host refused to apply the screen desired state.',
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}, { payload: request, required });
|
|
789
|
+
}
|
|
790
|
+
|
|
755
791
|
export function collectStoryboardDomTree() {
|
|
756
792
|
const visit = (node) => {
|
|
757
793
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
@@ -1564,6 +1600,10 @@ export async function captureStoryboardCase({
|
|
|
1564
1600
|
signal,
|
|
1565
1601
|
});
|
|
1566
1602
|
});
|
|
1603
|
+
await atCaptureStage('state', async () => {
|
|
1604
|
+
throwIfAborted(signal);
|
|
1605
|
+
await executeStoryboardDesiredState(page, screenCase, route);
|
|
1606
|
+
});
|
|
1567
1607
|
for (const interaction of screenCase.interactions ?? []) {
|
|
1568
1608
|
throwIfAborted(signal);
|
|
1569
1609
|
await atCaptureStage(
|
package/node/vite.mjs
CHANGED
|
@@ -263,6 +263,7 @@ export function createPygmalionVitePlugins(config) {
|
|
|
263
263
|
endpoint: project.preview.artifactEndpoint,
|
|
264
264
|
generateArtifact,
|
|
265
265
|
acquireLease: (label) => devMirror?.acquireLease(label) ?? null,
|
|
266
|
+
sourceRoot: () => devMirror?.activeAppRoot?.() ?? project.appRoot,
|
|
266
267
|
requestRuntime: () => devMirror?.requestRuntime?.(),
|
|
267
268
|
// No mirror plugin means no checkout to prepare, so generation is
|
|
268
269
|
// always allowed to proceed as it did before.
|