@pygmalionjs/pygmalion 0.5.18 → 0.5.20
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-CZOpvN6z.js → FrozenRoutePreview-DdLO8y9P.js} +1865 -1641
- package/dist-lib/pygmalion.js +7002 -6862
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/canvas/ShadowRoutePreview.d.ts +6 -0
- package/dist-lib/types/editor/captureSupply.d.ts +107 -0
- package/dist-lib/types/editor/designImport.d.ts +47 -1
- package/dist-lib/types/editor/documentStability.d.ts +35 -0
- package/dist-lib/types/editor/domImport.d.ts +5 -0
- package/dist-lib/types/editor/flowSessionScheduler.d.ts +6 -0
- package/dist-lib/types/editor/flowSessions.d.ts +33 -1
- package/dist-lib/types/editor/interactiveStates.d.ts +27 -0
- package/dist-lib/types/editor/mountScheduler.d.ts +31 -0
- package/dist-lib/types/editor/previewBootstrap.d.ts +55 -3
- package/dist-lib/types/editor/projectBootGate.d.ts +10 -0
- package/dist-lib/types/editor/routePreview.d.ts +27 -7
- package/dist-lib/types/editor/store.d.ts +6 -0
- package/dist-lib/types/editor/storyboardJourney.d.ts +37 -0
- package/dist-lib/types/lib.d.ts +21 -1
- package/dist-lib/types/shell/LayerTree.d.ts +6 -3
- package/dist-lib/types/shell/StoryboardGraphPanel.d.ts +1 -9
- package/node/dev-mirror.mjs +11 -0
- package/node/preview-artifact-plugin.mjs +237 -40
- package/node/storyboard-capture-runtime.mjs +97 -9
- package/node/vite.mjs +4 -0
- package/package.json +1 -1
- package/dist-lib/types/canvas/StoryboardConnections.d.ts +0 -31
- package/dist-lib/types/shell/AssetsPanel.d.ts +0 -3
|
@@ -11,6 +11,24 @@ const MAX_IDENTITY_LENGTH = 1_024;
|
|
|
11
11
|
const MAX_CAPTURE_BASE_URL_LENGTH = 2_048;
|
|
12
12
|
const MAX_FRAME_REQUEST_BYTES = 64 * 1024;
|
|
13
13
|
const MAX_FRAME_REQUEST_COUNT = 2_048;
|
|
14
|
+
// A batch that generates carries a recipe per frame, which a query string
|
|
15
|
+
// cannot hold past a handful of frames (Node refuses the request at 16KB of
|
|
16
|
+
// headers). Those arrive as a JSON body instead; the ceiling keeps a stray
|
|
17
|
+
// client from parking megabytes in memory.
|
|
18
|
+
const MAX_FRAME_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
19
|
+
/**
|
|
20
|
+
* Where a request sits among captures waiting on the same identity. Smaller
|
|
21
|
+
* runs first. The editor sends a frame the designer selected ahead of a
|
|
22
|
+
* canvas being filled, and a canvas being filled ahead of one nobody is
|
|
23
|
+
* looking at; a request that says nothing sits with the canvas.
|
|
24
|
+
*/
|
|
25
|
+
export const PREVIEW_CAPTURE_PRIORITY = Object.freeze({
|
|
26
|
+
selected: 0,
|
|
27
|
+
canvas: 10,
|
|
28
|
+
background: 20,
|
|
29
|
+
});
|
|
30
|
+
const DEFAULT_CAPTURE_PRIORITY = PREVIEW_CAPTURE_PRIORITY.canvas;
|
|
31
|
+
const MAX_CAPTURE_PRIORITY = 1_000;
|
|
14
32
|
|
|
15
33
|
function isLocalRequestHost(value) {
|
|
16
34
|
const raw = Array.isArray(value) ? value[0] : value;
|
|
@@ -33,15 +51,25 @@ function sendJson(response, statusCode, body) {
|
|
|
33
51
|
response.end(payload);
|
|
34
52
|
}
|
|
35
53
|
|
|
36
|
-
function requestedIdentity(
|
|
37
|
-
const value =
|
|
54
|
+
function requestedIdentity(params, key) {
|
|
55
|
+
const value = params.get(key);
|
|
38
56
|
return value && value === value.trim() && value.length <= MAX_IDENTITY_LENGTH
|
|
39
57
|
? value
|
|
40
58
|
: null;
|
|
41
59
|
}
|
|
42
60
|
|
|
43
|
-
function
|
|
44
|
-
const
|
|
61
|
+
function requestedPriority(params) {
|
|
62
|
+
const raw = params.get('priority');
|
|
63
|
+
if (raw == null || raw === '') return DEFAULT_CAPTURE_PRIORITY;
|
|
64
|
+
const value = Number(raw);
|
|
65
|
+
if (!Number.isInteger(value) || value < 0 || value > MAX_CAPTURE_PRIORITY) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requestedCaptureBaseUrl(params, requestHost) {
|
|
72
|
+
const value = params.get('captureBaseUrl');
|
|
45
73
|
if (!value) return undefined;
|
|
46
74
|
if (value !== value.trim() || value.length > MAX_CAPTURE_BASE_URL_LENGTH) {
|
|
47
75
|
return null;
|
|
@@ -79,27 +107,40 @@ function requestedCaptureBaseUrl(url, requestHost) {
|
|
|
79
107
|
* without starting work — otherwise naming every frame on mount would capture
|
|
80
108
|
* the whole catalog, which is the cost this mode exists to remove.
|
|
81
109
|
*/
|
|
82
|
-
function generationAllowed(
|
|
83
|
-
const raw =
|
|
110
|
+
function generationAllowed(params) {
|
|
111
|
+
const raw = params.get('generate');
|
|
84
112
|
if (raw == null) return true;
|
|
85
113
|
return !['0', 'false', 'no'].includes(raw.trim().toLowerCase());
|
|
86
114
|
}
|
|
87
115
|
|
|
88
|
-
function resolutionOnly(
|
|
89
|
-
const raw =
|
|
116
|
+
function resolutionOnly(params) {
|
|
117
|
+
const raw = params.get('resolve');
|
|
90
118
|
return raw != null && ['1', 'true', 'yes'].includes(raw.trim().toLowerCase());
|
|
91
119
|
}
|
|
92
120
|
|
|
93
|
-
|
|
94
|
-
|
|
121
|
+
/**
|
|
122
|
+
* The frame list of a request, whichever way it arrived.
|
|
123
|
+
*
|
|
124
|
+
* A GET carries it as JSON in the `frames` query parameter, capped so it fits a
|
|
125
|
+
* query string; a POST carries it in the body, where a batch of recipes fits.
|
|
126
|
+
*/
|
|
127
|
+
function requestedFrames(params) {
|
|
128
|
+
const raw = params.get('frames');
|
|
95
129
|
if (raw == null) return undefined;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
parsed = JSON.parse(raw);
|
|
100
|
-
} catch {
|
|
130
|
+
const parsedBody = params.parsedFrames;
|
|
131
|
+
if (parsedBody === undefined && Buffer.byteLength(raw) > MAX_FRAME_REQUEST_BYTES) {
|
|
101
132
|
return null;
|
|
102
133
|
}
|
|
134
|
+
let parsed;
|
|
135
|
+
if (parsedBody !== undefined) {
|
|
136
|
+
parsed = parsedBody;
|
|
137
|
+
} else {
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(raw);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
103
144
|
if (!Array.isArray(parsed) || parsed.length > MAX_FRAME_REQUEST_COUNT) return null;
|
|
104
145
|
const wanted = [];
|
|
105
146
|
for (const entry of parsed) {
|
|
@@ -113,11 +154,75 @@ function requestedFrames(url) {
|
|
|
113
154
|
if (typeof fingerprint !== 'string' || !fingerprint.trim()) return null;
|
|
114
155
|
if (fingerprint.length > MAX_IDENTITY_LENGTH) return null;
|
|
115
156
|
}
|
|
116
|
-
|
|
157
|
+
// The recipe the fingerprint stands for. A fingerprint is a hash — a
|
|
158
|
+
// generator handed one alone can only re-capture what the host declared for
|
|
159
|
+
// that id, which is a different screen wearing the requested name. Carried
|
|
160
|
+
// through opaquely: this plugin never interprets it, and the whole request
|
|
161
|
+
// is already capped by MAX_FRAME_REQUEST_BYTES.
|
|
162
|
+
const recipe = item.recipe;
|
|
163
|
+
if (recipe !== undefined) {
|
|
164
|
+
if (typeof recipe !== 'object' || recipe === null || Array.isArray(recipe)) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
wanted.push({
|
|
169
|
+
id,
|
|
170
|
+
...(fingerprint === undefined ? {} : { fingerprint }),
|
|
171
|
+
...(recipe === undefined ? {} : { recipe }),
|
|
172
|
+
});
|
|
117
173
|
}
|
|
118
174
|
return wanted;
|
|
119
175
|
}
|
|
120
176
|
|
|
177
|
+
/**
|
|
178
|
+
* The parameters of a request, read the same way for both transports.
|
|
179
|
+
*
|
|
180
|
+
* GET keeps every field in the query string. POST carries a JSON object with
|
|
181
|
+
* the same field names; scalar fields are read as strings so the validators
|
|
182
|
+
* above apply unchanged, and `frames` is kept parsed so a large batch is not
|
|
183
|
+
* re-serialized only to be parsed again.
|
|
184
|
+
*/
|
|
185
|
+
function paramsFromQuery(url) {
|
|
186
|
+
return { get: (key) => url.searchParams.get(key), parsedFrames: undefined };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function paramsFromBody(body) {
|
|
190
|
+
const scalar = (value) => {
|
|
191
|
+
if (value === undefined || value === null) return null;
|
|
192
|
+
if (typeof value === 'string') return value;
|
|
193
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
|
194
|
+
return null;
|
|
195
|
+
};
|
|
196
|
+
return {
|
|
197
|
+
get: (key) => {
|
|
198
|
+
if (key === 'frames') return body.frames === undefined ? null : '[]';
|
|
199
|
+
return scalar(body[key]);
|
|
200
|
+
},
|
|
201
|
+
parsedFrames: body.frames,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function readJsonBody(request, limit) {
|
|
206
|
+
const chunks = [];
|
|
207
|
+
let received = 0;
|
|
208
|
+
for await (const chunk of request) {
|
|
209
|
+
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
210
|
+
received += buffer.length;
|
|
211
|
+
if (received > limit) return { ok: false, error: 'body_too_large' };
|
|
212
|
+
chunks.push(buffer);
|
|
213
|
+
}
|
|
214
|
+
if (received === 0) return { ok: false, error: 'invalid_body' };
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
217
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
218
|
+
return { ok: false, error: 'invalid_body' };
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, body: parsed };
|
|
221
|
+
} catch {
|
|
222
|
+
return { ok: false, error: 'invalid_body' };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
121
226
|
function exactArtifact(artifact, namespace, sourceRevision) {
|
|
122
227
|
const validation = validateRoutePreviewArtifactBundle(artifact);
|
|
123
228
|
return (
|
|
@@ -140,6 +245,8 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
140
245
|
disabled = () => false,
|
|
141
246
|
generateArtifact,
|
|
142
247
|
acquireLease,
|
|
248
|
+
requestRuntime,
|
|
249
|
+
runtimePrepared = () => true,
|
|
143
250
|
} = {}) {
|
|
144
251
|
const resolvedRoot = path.resolve(root);
|
|
145
252
|
const resolvedArtifact = path.resolve(resolvedRoot, artifactFile);
|
|
@@ -165,6 +272,16 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
165
272
|
'Preview artifact generator option must be a function.',
|
|
166
273
|
);
|
|
167
274
|
}
|
|
275
|
+
if (requestRuntime != null && typeof requestRuntime !== 'function') {
|
|
276
|
+
throw new TypeError(
|
|
277
|
+
'Preview artifact runtime request option must be a function.',
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (typeof runtimePrepared !== 'function') {
|
|
281
|
+
throw new TypeError(
|
|
282
|
+
'Preview artifact runtime readiness option must be a function.',
|
|
283
|
+
);
|
|
284
|
+
}
|
|
168
285
|
|
|
169
286
|
const artifactStore = createPreviewArtifactStore({
|
|
170
287
|
artifactFile: resolvedArtifact,
|
|
@@ -175,20 +292,52 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
175
292
|
const identityQueues = new Map();
|
|
176
293
|
const inFlightRequests = new Map();
|
|
177
294
|
|
|
178
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Captures on one identity run one at a time — they share the checkout and
|
|
297
|
+
* the worker — but not in the order they were asked. A frame the designer
|
|
298
|
+
* just selected must not sit behind a canvas being filled in the background,
|
|
299
|
+
* so the queue is ordered by priority and by arrival within a priority.
|
|
300
|
+
* The running capture is never interrupted; the next pick happens when it
|
|
301
|
+
* finishes.
|
|
302
|
+
*/
|
|
303
|
+
function enqueueIdentity(namespace, sourceRevision, task, priority = DEFAULT_CAPTURE_PRIORITY) {
|
|
179
304
|
const identity = `${namespace}\u0000${sourceRevision}`;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
(
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
305
|
+
let queue = identityQueues.get(identity);
|
|
306
|
+
if (!queue) {
|
|
307
|
+
queue = { pending: [], running: false, arrivals: 0 };
|
|
308
|
+
identityQueues.set(identity, queue);
|
|
309
|
+
}
|
|
310
|
+
return new Promise((resolve, reject) => {
|
|
311
|
+
queue.arrivals += 1;
|
|
312
|
+
queue.pending.push({
|
|
313
|
+
priority,
|
|
314
|
+
arrival: queue.arrivals,
|
|
315
|
+
run: task,
|
|
316
|
+
resolve,
|
|
317
|
+
reject,
|
|
318
|
+
});
|
|
319
|
+
queue.pending.sort(
|
|
320
|
+
(left, right) => left.priority - right.priority || left.arrival - right.arrival,
|
|
321
|
+
);
|
|
322
|
+
pumpIdentity(identity, queue);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function pumpIdentity(identity, queue) {
|
|
327
|
+
if (queue.running) return;
|
|
328
|
+
const next = queue.pending.shift();
|
|
329
|
+
if (!next) {
|
|
330
|
+
if (identityQueues.get(identity) === queue) identityQueues.delete(identity);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
queue.running = true;
|
|
334
|
+
Promise.resolve()
|
|
335
|
+
.then(next.run)
|
|
336
|
+
.then(next.resolve, next.reject)
|
|
337
|
+
.finally(() => {
|
|
338
|
+
queue.running = false;
|
|
339
|
+
pumpIdentity(identity, queue);
|
|
340
|
+
});
|
|
192
341
|
}
|
|
193
342
|
|
|
194
343
|
function deduplicateRequest(key, task) {
|
|
@@ -230,6 +379,26 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
230
379
|
});
|
|
231
380
|
}
|
|
232
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Answers a frame whose capture cannot run yet, and starts what it needs.
|
|
384
|
+
*
|
|
385
|
+
* Asking for a frame is asking for the checkout — the capture reads it. The
|
|
386
|
+
* editor holds no preview origin until the mirror is ready, so this endpoint
|
|
387
|
+
* is the only place that demand arrives before one exists. Without starting
|
|
388
|
+
* the work here the canvas waited on a preparation nobody had ordered, and
|
|
389
|
+
* the only way out was a human pressing refresh.
|
|
390
|
+
*/
|
|
391
|
+
function answerPreparing(response) {
|
|
392
|
+
requestRuntime?.();
|
|
393
|
+
sendJson(response, 503, {
|
|
394
|
+
ok: false,
|
|
395
|
+
error: 'artifact_generation_unavailable',
|
|
396
|
+
retryable: true,
|
|
397
|
+
preparing: true,
|
|
398
|
+
details: ['The dev screen checkout is being prepared.'],
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
233
402
|
/**
|
|
234
403
|
* A generator that ran and produced the wrong thing.
|
|
235
404
|
*
|
|
@@ -300,7 +469,7 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
300
469
|
* The stored bundle becomes a cache keyed per frame rather than one artifact
|
|
301
470
|
* per revision, so replacing one screen no longer re-renders the others.
|
|
302
471
|
*/
|
|
303
|
-
function generateFrames(namespace, sourceRevision, captureBaseUrl, wanted) {
|
|
472
|
+
function generateFrames(namespace, sourceRevision, captureBaseUrl, wanted, priority) {
|
|
304
473
|
const request = `frames\u0000${namespace}\u0000${sourceRevision}\u0000${wanted
|
|
305
474
|
.map((frame) => `${frame.id}@${frame.fingerprint ?? ''}`)
|
|
306
475
|
.sort()
|
|
@@ -346,7 +515,7 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
346
515
|
wanted,
|
|
347
516
|
)
|
|
348
517
|
).bundle;
|
|
349
|
-
}),
|
|
518
|
+
}, priority),
|
|
350
519
|
);
|
|
351
520
|
}
|
|
352
521
|
|
|
@@ -365,8 +534,8 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
365
534
|
next();
|
|
366
535
|
return;
|
|
367
536
|
}
|
|
368
|
-
if (request.method !== 'GET') {
|
|
369
|
-
response.setHeader('allow', 'GET');
|
|
537
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
538
|
+
response.setHeader('allow', 'GET, POST');
|
|
370
539
|
sendJson(response, 405, {
|
|
371
540
|
ok: false,
|
|
372
541
|
error: 'method_not_allowed',
|
|
@@ -380,14 +549,33 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
380
549
|
});
|
|
381
550
|
return;
|
|
382
551
|
}
|
|
383
|
-
|
|
384
|
-
|
|
552
|
+
let params;
|
|
553
|
+
if (request.method === 'POST') {
|
|
554
|
+
const read = await readJsonBody(request, MAX_FRAME_REQUEST_BODY_BYTES);
|
|
555
|
+
if (!read.ok) {
|
|
556
|
+
sendJson(response, read.error === 'body_too_large' ? 413 : 400, {
|
|
557
|
+
ok: false,
|
|
558
|
+
error: read.error,
|
|
559
|
+
});
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
params = paramsFromBody(read.body);
|
|
563
|
+
} else {
|
|
564
|
+
params = paramsFromQuery(url);
|
|
565
|
+
}
|
|
566
|
+
const namespace = requestedIdentity(params, 'namespace');
|
|
567
|
+
const sourceRevision = requestedIdentity(params, 'sourceRevision');
|
|
385
568
|
const captureBaseUrl = requestedCaptureBaseUrl(
|
|
386
|
-
|
|
569
|
+
params,
|
|
387
570
|
Array.isArray(request.headers.host)
|
|
388
571
|
? request.headers.host[0]
|
|
389
572
|
: request.headers.host,
|
|
390
573
|
);
|
|
574
|
+
const priority = requestedPriority(params);
|
|
575
|
+
if (priority === null) {
|
|
576
|
+
sendJson(response, 400, { ok: false, error: 'invalid_priority' });
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
391
579
|
if (!namespace || !sourceRevision) {
|
|
392
580
|
sendJson(response, 400, {
|
|
393
581
|
ok: false,
|
|
@@ -416,13 +604,13 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
416
604
|
return;
|
|
417
605
|
}
|
|
418
606
|
|
|
419
|
-
const wanted = requestedFrames(
|
|
607
|
+
const wanted = requestedFrames(params);
|
|
420
608
|
if (wanted === null) {
|
|
421
609
|
sendJson(response, 400, { ok: false, error: 'invalid_frames' });
|
|
422
610
|
return;
|
|
423
611
|
}
|
|
424
612
|
if (wanted !== undefined) {
|
|
425
|
-
if (resolutionOnly(
|
|
613
|
+
if (resolutionOnly(params)) {
|
|
426
614
|
const resolution = await artifactStore.resolveFrameSelection(
|
|
427
615
|
namespace,
|
|
428
616
|
sourceRevision,
|
|
@@ -449,7 +637,11 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
449
637
|
wanted,
|
|
450
638
|
);
|
|
451
639
|
const absent = [...picked.missing, ...picked.stale];
|
|
452
|
-
if (absent.length && generateArtifact && generationAllowed(
|
|
640
|
+
if (absent.length && generateArtifact && generationAllowed(params)) {
|
|
641
|
+
if (!runtimePrepared()) {
|
|
642
|
+
answerPreparing(response);
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
453
645
|
const requested = new Set(absent);
|
|
454
646
|
try {
|
|
455
647
|
await generateFrames(
|
|
@@ -457,6 +649,7 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
457
649
|
sourceRevision,
|
|
458
650
|
captureBaseUrl,
|
|
459
651
|
wanted.filter((frame) => requested.has(frame.id)),
|
|
652
|
+
priority,
|
|
460
653
|
);
|
|
461
654
|
picked = await artifactStore.readFrameSelection(
|
|
462
655
|
namespace,
|
|
@@ -480,12 +673,16 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
480
673
|
// A consumer that forbids capture must be obeyed in every mode. Honouring
|
|
481
674
|
// it only for frame requests left the expensive path one missing
|
|
482
675
|
// parameter away.
|
|
483
|
-
const mayGenerate = generateArtifact && generationAllowed(
|
|
676
|
+
const mayGenerate = generateArtifact && generationAllowed(params);
|
|
484
677
|
let artifact = await artifactStore.readExactArtifact(
|
|
485
678
|
namespace,
|
|
486
679
|
sourceRevision,
|
|
487
680
|
);
|
|
488
681
|
if (!artifact && mayGenerate) {
|
|
682
|
+
if (!runtimePrepared()) {
|
|
683
|
+
answerPreparing(response);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
489
686
|
try {
|
|
490
687
|
artifact = await generateExactArtifact(
|
|
491
688
|
namespace,
|
|
@@ -704,6 +704,11 @@ export function storyboardDocumentStabilitySignature(volatileSelectors = []) {
|
|
|
704
704
|
selectorKey,
|
|
705
705
|
observer: null,
|
|
706
706
|
revision: 0,
|
|
707
|
+
// What kept changing, by element, so a screen that never settles can say
|
|
708
|
+
// which part of it would not stop — the alternative is a capture that
|
|
709
|
+
// reports "unstable" and a person guessing which widget to declare
|
|
710
|
+
// volatile. Bounded: a screen has a handful of movers, not thousands.
|
|
711
|
+
churn: new Map(),
|
|
707
712
|
};
|
|
708
713
|
// A screen can hold something that never stops changing — an elapsed clock, a
|
|
709
714
|
// level meter, a marquee. Counting those makes the document look unstable
|
|
@@ -719,6 +724,35 @@ export function storyboardDocumentStabilitySignature(volatileSelectors = []) {
|
|
|
719
724
|
return false;
|
|
720
725
|
}
|
|
721
726
|
};
|
|
727
|
+
const describe = (node) => {
|
|
728
|
+
const element =
|
|
729
|
+
node && node.nodeType === 1 ? node : (node && node.parentElement) || null;
|
|
730
|
+
if (!element) return '(text)';
|
|
731
|
+
const tag = String(element.tagName || '').toLowerCase();
|
|
732
|
+
const id = element.id ? `#${element.id}` : '';
|
|
733
|
+
const classes = String(element.getAttribute?.('class') ?? '')
|
|
734
|
+
.split(/\s+/)
|
|
735
|
+
.filter(Boolean)
|
|
736
|
+
.slice(0, 2)
|
|
737
|
+
.map((name) => `.${name}`)
|
|
738
|
+
.join('');
|
|
739
|
+
const testId = element.getAttribute?.('data-testid');
|
|
740
|
+
return `${tag}${id}${classes}${testId ? `[data-testid=${testId}]` : ''}`;
|
|
741
|
+
};
|
|
742
|
+
const countChurn = (records) => {
|
|
743
|
+
for (const record of records) {
|
|
744
|
+
if (isVolatile(record.target)) continue;
|
|
745
|
+
const key = describe(record.target);
|
|
746
|
+
const known = tracker.churn.get(key);
|
|
747
|
+
if (known != null) {
|
|
748
|
+
tracker.churn.set(key, known + 1);
|
|
749
|
+
} else if (tracker.churn.size < 64) {
|
|
750
|
+
tracker.churn.set(key, 1);
|
|
751
|
+
} else {
|
|
752
|
+
tracker.churn.set('(other)', (tracker.churn.get('(other)') ?? 0) + 1);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
};
|
|
722
756
|
if (
|
|
723
757
|
document.documentElement &&
|
|
724
758
|
typeof runtime.MutationObserver === 'function'
|
|
@@ -728,6 +762,7 @@ export function storyboardDocumentStabilitySignature(volatileSelectors = []) {
|
|
|
728
762
|
if (batch.length > 0 && batch.every((record) => isVolatile(record.target))) {
|
|
729
763
|
return;
|
|
730
764
|
}
|
|
765
|
+
countChurn(batch);
|
|
731
766
|
tracker.revision += 1;
|
|
732
767
|
});
|
|
733
768
|
tracker.observer.observe(document.documentElement, {
|
|
@@ -782,6 +817,47 @@ export function storyboardDocumentStabilitySignature(volatileSelectors = []) {
|
|
|
782
817
|
});
|
|
783
818
|
}
|
|
784
819
|
|
|
820
|
+
/**
|
|
821
|
+
* What kept the document from settling, most active first.
|
|
822
|
+
*
|
|
823
|
+
* Runs inside the page: the stability tracker keeps a bounded count of
|
|
824
|
+
* mutation targets that were not declared volatile, and this reads it back so
|
|
825
|
+
* a failed wait can name the mover instead of only saying "unstable".
|
|
826
|
+
*/
|
|
827
|
+
export function storyboardDocumentChurnReport(limit = 6) {
|
|
828
|
+
const tracker = window['__PYGMALION_STORYBOARD_STABILITY__'];
|
|
829
|
+
const churn = tracker?.churn;
|
|
830
|
+
if (!churn || typeof churn.entries !== 'function') return [];
|
|
831
|
+
return [...churn.entries()]
|
|
832
|
+
.sort((left, right) => right[1] - left[1])
|
|
833
|
+
.slice(0, Math.max(0, limit))
|
|
834
|
+
.map(([target, count]) => ({ target, count }));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/** One line naming what a screen kept changing, for a stability failure. */
|
|
838
|
+
export function formatStoryboardChurn(report) {
|
|
839
|
+
if (!Array.isArray(report) || report.length === 0) return '';
|
|
840
|
+
const parts = report
|
|
841
|
+
.filter((entry) => entry && typeof entry.target === 'string')
|
|
842
|
+
.map((entry) => `${entry.target} ×${Number(entry.count) || 0}`);
|
|
843
|
+
return parts.length ? ` Still changing: ${parts.join(', ')}.` : '';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
async function describeStoryboardChurn(page) {
|
|
847
|
+
try {
|
|
848
|
+
const report = await page.evaluate(storyboardDocumentChurnReport, 6);
|
|
849
|
+
return formatStoryboardChurn(report);
|
|
850
|
+
} catch {
|
|
851
|
+
return '';
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function unstableDocumentError(churn) {
|
|
856
|
+
return new Error(
|
|
857
|
+
`The rendered document did not reach a stable DOM and overlay state.${churn}`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
785
861
|
export async function waitForStableStoryboardDocument(
|
|
786
862
|
page,
|
|
787
863
|
{
|
|
@@ -1148,17 +1224,23 @@ async function collectStableEvidence(
|
|
|
1148
1224
|
screenshotOptions,
|
|
1149
1225
|
screenshotScale = STORYBOARD_CAPTURE_SCREENSHOT_SCALE,
|
|
1150
1226
|
stability = {},
|
|
1227
|
+
// The stabilize stage already froze the page and saw it hold still. Waiting
|
|
1228
|
+
// again here measured as the single largest cost of a capture (two rounds
|
|
1229
|
+
// of the same wait, ~40% of the whole), and a document that was still a
|
|
1230
|
+
// moment ago is not made stiller by asking twice.
|
|
1231
|
+
frozenStyleApplied = false,
|
|
1232
|
+
alreadyStable = false,
|
|
1151
1233
|
},
|
|
1152
1234
|
) {
|
|
1153
1235
|
const evidence = {};
|
|
1154
1236
|
const errors = [];
|
|
1155
1237
|
try {
|
|
1156
|
-
await page.addStyleTag({ content: FROZEN_STYLE });
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1238
|
+
if (!frozenStyleApplied) await page.addStyleTag({ content: FROZEN_STYLE });
|
|
1239
|
+
if (!alreadyStable) {
|
|
1240
|
+
const stable = await waitForStableStoryboardDocument(page, stability);
|
|
1241
|
+
if (!stable) {
|
|
1242
|
+
throw unstableDocumentError(await describeStoryboardChurn(page));
|
|
1243
|
+
}
|
|
1162
1244
|
}
|
|
1163
1245
|
} catch (error) {
|
|
1164
1246
|
errors.push(new StoryboardCaptureStageError('stabilize', error));
|
|
@@ -1306,6 +1388,10 @@ export async function captureStoryboardCase({
|
|
|
1306
1388
|
let primaryError = null;
|
|
1307
1389
|
let evidence = {};
|
|
1308
1390
|
const evidenceErrors = [];
|
|
1391
|
+
// Set by the stabilize stage; evidence collection reuses both instead of
|
|
1392
|
+
// freezing and waiting a second time.
|
|
1393
|
+
let frozenStyleApplied = Boolean(session?.frozen);
|
|
1394
|
+
let stabilized = false;
|
|
1309
1395
|
try {
|
|
1310
1396
|
throwIfAborted(signal);
|
|
1311
1397
|
if (!warm) {
|
|
@@ -1394,12 +1480,12 @@ export async function captureStoryboardCase({
|
|
|
1394
1480
|
await page.addStyleTag({ content: FROZEN_STYLE });
|
|
1395
1481
|
if (session) session.frozen = true;
|
|
1396
1482
|
}
|
|
1483
|
+
frozenStyleApplied = true;
|
|
1397
1484
|
const stable = await waitForStableStoryboardDocument(page, stability);
|
|
1398
1485
|
if (!stable) {
|
|
1399
|
-
throw
|
|
1400
|
-
'The rendered document did not reach a stable DOM and overlay state.',
|
|
1401
|
-
);
|
|
1486
|
+
throw unstableDocumentError(await describeStoryboardChurn(page));
|
|
1402
1487
|
}
|
|
1488
|
+
stabilized = true;
|
|
1403
1489
|
});
|
|
1404
1490
|
await atCaptureStage('assertion', async () => {
|
|
1405
1491
|
await assertFinalStoryboardState(page, screenCase.assertions ?? []);
|
|
@@ -1420,6 +1506,8 @@ export async function captureStoryboardCase({
|
|
|
1420
1506
|
screenshotOptions,
|
|
1421
1507
|
screenshotScale: resolveScreenshotScale(contextOptions),
|
|
1422
1508
|
stability,
|
|
1509
|
+
frozenStyleApplied,
|
|
1510
|
+
alreadyStable: stabilized,
|
|
1423
1511
|
});
|
|
1424
1512
|
evidence = collected.evidence;
|
|
1425
1513
|
evidenceErrors.push(...collected.errors);
|
package/node/vite.mjs
CHANGED
|
@@ -258,6 +258,10 @@ export function createPygmalionVitePlugins(config) {
|
|
|
258
258
|
endpoint: project.preview.artifactEndpoint,
|
|
259
259
|
generateArtifact,
|
|
260
260
|
acquireLease: (label) => devMirror?.acquireLease(label) ?? null,
|
|
261
|
+
requestRuntime: () => devMirror?.requestRuntime?.(),
|
|
262
|
+
// No mirror plugin means no checkout to prepare, so generation is
|
|
263
|
+
// always allowed to proceed as it did before.
|
|
264
|
+
runtimePrepared: () => devMirror?.runtimePrepared?.() ?? true,
|
|
261
265
|
}),
|
|
262
266
|
);
|
|
263
267
|
}
|
package/package.json
CHANGED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import type { StoryboardGraphViewModel } from '../editor/storyboardGraphView';
|
|
2
|
-
export interface StoryboardFrameGeometry {
|
|
3
|
-
frameId: string;
|
|
4
|
-
x: number;
|
|
5
|
-
y: number;
|
|
6
|
-
width: number;
|
|
7
|
-
height: number;
|
|
8
|
-
}
|
|
9
|
-
export interface StoryboardConnectionPath {
|
|
10
|
-
id: string;
|
|
11
|
-
sourceFrameId: string;
|
|
12
|
-
targetFrameId: string;
|
|
13
|
-
path: string;
|
|
14
|
-
}
|
|
15
|
-
export type StoryboardConnectionMode = 'story' | 'focus' | 'overview';
|
|
16
|
-
export interface StoryboardConnectionPathOptions {
|
|
17
|
-
mode?: StoryboardConnectionMode;
|
|
18
|
-
activeFrameId?: string | null;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Projects graph transitions into world-space paths. The calculation is kept
|
|
22
|
-
* independent from camera pan and zoom because the SVG lives inside the same
|
|
23
|
-
* transformed canvas layer as the frames.
|
|
24
|
-
*/
|
|
25
|
-
export declare function createStoryboardConnectionPaths(model: StoryboardGraphViewModel, frames: readonly StoryboardFrameGeometry[], options?: StoryboardConnectionPathOptions): StoryboardConnectionPath[];
|
|
26
|
-
export declare function StoryboardConnections({ model, frames, mode, activeFrameId, }: {
|
|
27
|
-
model: StoryboardGraphViewModel;
|
|
28
|
-
frames: readonly StoryboardFrameGeometry[];
|
|
29
|
-
mode?: StoryboardConnectionMode;
|
|
30
|
-
activeFrameId?: string | null;
|
|
31
|
-
}): import("react").JSX.Element | null;
|