@pygmalionjs/pygmalion 0.6.1 → 0.6.3
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 +3 -1
- package/dist-lib/{FrozenRoutePreview-3yddEUJs.js → FrozenRoutePreview-V9-gxIyv.js} +1744 -1445
- package/dist-lib/pygmalion.js +6424 -6092
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/dist-lib/types/editor/automaticMotion.d.ts +24 -0
- package/dist-lib/types/editor/automaticPseudoStates.d.ts +1 -0
- package/dist-lib/types/editor/previewBootstrap.d.ts +1 -1
- package/dist-lib/types/editor/previewCaptureProgress.d.ts +20 -0
- package/dist-lib/types/editor/shadowPreview.d.ts +9 -0
- package/dist-lib/types/editor/store.d.ts +8 -0
- package/dist-lib/types/lib.d.ts +2 -0
- package/docs/screen-state-contract.md +23 -1
- package/node/automatic-motion-runtime.mjs +279 -0
- package/node/automatic-pseudo-runtime.mjs +8 -1
- package/node/preview-artifact-plugin.mjs +198 -49
- package/node/preview-capture-worker.mjs +80 -7
- package/node/storyboard-canonical.mjs +1 -1
- package/node/storyboard-capture-runtime.mjs +29 -8
- package/node/vite.mjs +2 -0
- package/package.json +2 -1
- package/vite.d.ts +40 -18
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export function annotateStoryboardAutomaticPseudoStates() {
|
|
9
9
|
const metadataAttribute = 'data-pygmalion-auto-pseudo';
|
|
10
|
+
const ledgerAttribute = 'data-pygmalion-auto-pseudos';
|
|
10
11
|
const eventAttribute = 'data-pygmalion-pseudo-events';
|
|
11
12
|
const stateOrder = ['hover', 'focus-visible', 'active'];
|
|
12
13
|
const pseudoPattern =
|
|
@@ -277,6 +278,10 @@ export function annotateStoryboardAutomaticPseudoStates() {
|
|
|
277
278
|
if (states.size > 0) found.set(element, states);
|
|
278
279
|
}
|
|
279
280
|
const selectorForElement = (element) => {
|
|
281
|
+
if (element === document.documentElement) {
|
|
282
|
+
return { selector: 'html', index: 0 };
|
|
283
|
+
}
|
|
284
|
+
if (element === document.body) return { selector: 'body', index: 0 };
|
|
280
285
|
for (const name of [
|
|
281
286
|
'data-testid',
|
|
282
287
|
'data-pygmalion-own-source',
|
|
@@ -356,7 +361,7 @@ export function annotateStoryboardAutomaticPseudoStates() {
|
|
|
356
361
|
totals.set(entry.baseLabel, (totals.get(entry.baseLabel) ?? 0) + 1);
|
|
357
362
|
}
|
|
358
363
|
const seen = new Map();
|
|
359
|
-
|
|
364
|
+
const targets = pending.map(({ element, identity, baseLabel, states }) => {
|
|
360
365
|
const ordinal = (seen.get(baseLabel) ?? 0) + 1;
|
|
361
366
|
seen.set(baseLabel, ordinal);
|
|
362
367
|
const total = totals.get(baseLabel) ?? 1;
|
|
@@ -373,4 +378,6 @@ export function annotateStoryboardAutomaticPseudoStates() {
|
|
|
373
378
|
element.setAttribute(metadataAttribute, JSON.stringify(target));
|
|
374
379
|
return target;
|
|
375
380
|
});
|
|
381
|
+
document.body?.setAttribute(ledgerAttribute, JSON.stringify(targets));
|
|
382
|
+
return targets;
|
|
376
383
|
}
|
|
@@ -8,6 +8,7 @@ import { createPreviewArtifactStore } from './preview-artifact-store.mjs';
|
|
|
8
8
|
|
|
9
9
|
export const PYGMALION_PREVIEW_ARTIFACT_ENDPOINT =
|
|
10
10
|
'/__pygmalion-route-preview/artifact';
|
|
11
|
+
export const PYGMALION_PREVIEW_CAPTURE_PROGRESS_SUFFIX = '/progress';
|
|
11
12
|
export const DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE =
|
|
12
13
|
'artifacts/pygmalion-route-previews.json';
|
|
13
14
|
|
|
@@ -33,6 +34,17 @@ export const PREVIEW_CAPTURE_PRIORITY = Object.freeze({
|
|
|
33
34
|
});
|
|
34
35
|
const DEFAULT_CAPTURE_PRIORITY = PREVIEW_CAPTURE_PRIORITY.canvas;
|
|
35
36
|
const MAX_CAPTURE_PRIORITY = 1_000;
|
|
37
|
+
const CAPTURE_PROGRESS_PHASES = new Set([
|
|
38
|
+
'queued',
|
|
39
|
+
'preparing',
|
|
40
|
+
'capturing',
|
|
41
|
+
'retrying',
|
|
42
|
+
'finalizing',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
function captureProgressEndpoint(endpoint) {
|
|
46
|
+
return `${endpoint.replace(/\/$/, '')}${PYGMALION_PREVIEW_CAPTURE_PROGRESS_SUFFIX}`;
|
|
47
|
+
}
|
|
36
48
|
|
|
37
49
|
function isLocalRequestHost(value) {
|
|
38
50
|
const raw = Array.isArray(value) ? value[0] : value;
|
|
@@ -287,6 +299,7 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
287
299
|
'Preview artifact runtime readiness option must be a function.',
|
|
288
300
|
);
|
|
289
301
|
}
|
|
302
|
+
const progressEndpoint = captureProgressEndpoint(endpoint);
|
|
290
303
|
|
|
291
304
|
const artifactStore = createPreviewArtifactStore({
|
|
292
305
|
artifactFile: resolvedArtifact,
|
|
@@ -299,6 +312,103 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
299
312
|
});
|
|
300
313
|
const identityQueues = new Map();
|
|
301
314
|
const inFlightRequests = new Map();
|
|
315
|
+
const captureJobs = new Map();
|
|
316
|
+
let captureJobCounter = 0;
|
|
317
|
+
|
|
318
|
+
function readCaptureProgress() {
|
|
319
|
+
const jobs = [...captureJobs.values()];
|
|
320
|
+
if (jobs.length === 0) {
|
|
321
|
+
return {
|
|
322
|
+
active: false,
|
|
323
|
+
phase: 'idle',
|
|
324
|
+
completed: 0,
|
|
325
|
+
total: 0,
|
|
326
|
+
jobCount: 0,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const phases = new Set(jobs.map((job) => job.phase));
|
|
330
|
+
const phase = phases.has('capturing')
|
|
331
|
+
? 'capturing'
|
|
332
|
+
: phases.has('retrying')
|
|
333
|
+
? 'retrying'
|
|
334
|
+
: phases.has('finalizing')
|
|
335
|
+
? 'finalizing'
|
|
336
|
+
: phases.has('preparing')
|
|
337
|
+
? 'preparing'
|
|
338
|
+
: 'queued';
|
|
339
|
+
const latest = jobs.reduce((left, right) =>
|
|
340
|
+
left.updatedAt >= right.updatedAt ? left : right,
|
|
341
|
+
);
|
|
342
|
+
return {
|
|
343
|
+
active: true,
|
|
344
|
+
phase,
|
|
345
|
+
completed: jobs.reduce((sum, job) => sum + job.completed, 0),
|
|
346
|
+
total: jobs.reduce((sum, job) => sum + job.total, 0),
|
|
347
|
+
jobCount: jobs.length,
|
|
348
|
+
...(latest.frameId ? { frameId: latest.frameId } : {}),
|
|
349
|
+
...(latest.captureStatus
|
|
350
|
+
? { captureStatus: latest.captureStatus }
|
|
351
|
+
: {}),
|
|
352
|
+
updatedAt: latest.updatedAt,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function startCaptureProgress(expectedTotal = 0) {
|
|
357
|
+
captureJobCounter += 1;
|
|
358
|
+
const id = `preview-capture-${captureJobCounter}`;
|
|
359
|
+
const job = {
|
|
360
|
+
phase: 'preparing',
|
|
361
|
+
completed: 0,
|
|
362
|
+
total:
|
|
363
|
+
Number.isInteger(expectedTotal) && expectedTotal >= 0
|
|
364
|
+
? expectedTotal
|
|
365
|
+
: 0,
|
|
366
|
+
updatedAt: Date.now(),
|
|
367
|
+
};
|
|
368
|
+
captureJobs.set(id, job);
|
|
369
|
+
return {
|
|
370
|
+
update(progress) {
|
|
371
|
+
if (!progress || typeof progress !== 'object') return;
|
|
372
|
+
if (
|
|
373
|
+
progress.phase != null &&
|
|
374
|
+
!CAPTURE_PROGRESS_PHASES.has(progress.phase)
|
|
375
|
+
) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const completed = Number(progress.completed ?? job.completed);
|
|
379
|
+
const total = Number(progress.total ?? job.total);
|
|
380
|
+
if (
|
|
381
|
+
!Number.isInteger(completed) ||
|
|
382
|
+
!Number.isInteger(total) ||
|
|
383
|
+
completed < 0 ||
|
|
384
|
+
total < 0 ||
|
|
385
|
+
completed > total
|
|
386
|
+
) {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
job.phase = progress.phase ?? job.phase;
|
|
390
|
+
job.completed = completed;
|
|
391
|
+
job.total = total;
|
|
392
|
+
job.frameId =
|
|
393
|
+
typeof progress.frameId === 'string' && progress.frameId.trim()
|
|
394
|
+
? progress.frameId.trim()
|
|
395
|
+
: undefined;
|
|
396
|
+
job.captureStatus =
|
|
397
|
+
typeof progress.captureStatus === 'string' &&
|
|
398
|
+
progress.captureStatus.trim()
|
|
399
|
+
? progress.captureStatus.trim()
|
|
400
|
+
: undefined;
|
|
401
|
+
job.updatedAt = Date.now();
|
|
402
|
+
},
|
|
403
|
+
finalizing() {
|
|
404
|
+
job.phase = 'finalizing';
|
|
405
|
+
job.updatedAt = Date.now();
|
|
406
|
+
},
|
|
407
|
+
finish() {
|
|
408
|
+
captureJobs.delete(id);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
302
412
|
|
|
303
413
|
/**
|
|
304
414
|
* Captures on one identity run one at a time — they share the checkout and
|
|
@@ -446,27 +556,36 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
446
556
|
sourceRevision,
|
|
447
557
|
);
|
|
448
558
|
if (existing) return existing;
|
|
449
|
-
const
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
if (!exactArtifact(generated, namespace, sourceRevision)) {
|
|
461
|
-
throw badGeneratorOutput(
|
|
462
|
-
'Generated preview artifact identity does not match the request.',
|
|
559
|
+
const progress = startCaptureProgress();
|
|
560
|
+
try {
|
|
561
|
+
const generated = await withCaptureLease(sourceRevision, () =>
|
|
562
|
+
generateArtifact(
|
|
563
|
+
{
|
|
564
|
+
namespace,
|
|
565
|
+
sourceRevision,
|
|
566
|
+
...(captureBaseUrl ? { captureBaseUrl } : {}),
|
|
567
|
+
},
|
|
568
|
+
{ onProgress: progress.update },
|
|
569
|
+
),
|
|
463
570
|
);
|
|
571
|
+
const validation = validateRoutePreviewArtifactBundle(generated);
|
|
572
|
+
if (!validation.valid) {
|
|
573
|
+
throw badGeneratorOutput('Generated preview artifact is invalid.');
|
|
574
|
+
}
|
|
575
|
+
if (!exactArtifact(generated, namespace, sourceRevision)) {
|
|
576
|
+
throw badGeneratorOutput(
|
|
577
|
+
'Generated preview artifact identity does not match the request.',
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
progress.finalizing();
|
|
581
|
+
await artifactStore.publishArtifact(generated, {
|
|
582
|
+
sourceRevision,
|
|
583
|
+
recordRevision: true,
|
|
584
|
+
});
|
|
585
|
+
return generated;
|
|
586
|
+
} finally {
|
|
587
|
+
progress.finish();
|
|
464
588
|
}
|
|
465
|
-
await artifactStore.publishArtifact(generated, {
|
|
466
|
-
sourceRevision,
|
|
467
|
-
recordRevision: true,
|
|
468
|
-
});
|
|
469
|
-
return generated;
|
|
470
589
|
}),
|
|
471
590
|
);
|
|
472
591
|
}
|
|
@@ -494,39 +613,47 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
494
613
|
const absent = new Set([...before.missing, ...before.stale]);
|
|
495
614
|
const remaining = wanted.filter((frame) => absent.has(frame.id));
|
|
496
615
|
if (remaining.length === 0) return before;
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
616
|
+
const progress = startCaptureProgress(remaining.length);
|
|
617
|
+
try {
|
|
618
|
+
const generated = await withCaptureLease(sourceRevision, () =>
|
|
619
|
+
generateArtifact(
|
|
620
|
+
{
|
|
621
|
+
namespace,
|
|
622
|
+
sourceRevision,
|
|
623
|
+
frames: remaining,
|
|
624
|
+
...(captureBaseUrl ? { captureBaseUrl } : {}),
|
|
625
|
+
},
|
|
626
|
+
{ onProgress: progress.update },
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
const validation = validateRoutePreviewArtifactBundle(generated);
|
|
630
|
+
if (!validation.valid || generated.version !== 3) {
|
|
631
|
+
throw new Error('Generated preview artifact is invalid.');
|
|
632
|
+
}
|
|
633
|
+
if (!exactArtifact(generated, namespace, sourceRevision)) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
'Generated preview artifact identity does not match the request.',
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
progress.finalizing();
|
|
639
|
+
await artifactStore.publishArtifact(generated, {
|
|
501
640
|
sourceRevision,
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
if (!exactArtifact(generated, namespace, sourceRevision)) {
|
|
511
|
-
throw new Error(
|
|
512
|
-
'Generated preview artifact identity does not match the request.',
|
|
641
|
+
recordRevision: false,
|
|
642
|
+
});
|
|
643
|
+
// Retention is allowed to decline a newly generated object when older
|
|
644
|
+
// entries have earned higher read recency. That cache decision must not
|
|
645
|
+
// discard the result from the request that just paid to generate it.
|
|
646
|
+
const responseArtifact = mergeRoutePreviewArtifactV3(
|
|
647
|
+
before.bundle,
|
|
648
|
+
generated,
|
|
513
649
|
);
|
|
650
|
+
return selectRoutePreviewArtifactFrames(responseArtifact, wanted, {
|
|
651
|
+
includeStale: true,
|
|
652
|
+
sourceRevision,
|
|
653
|
+
});
|
|
654
|
+
} finally {
|
|
655
|
+
progress.finish();
|
|
514
656
|
}
|
|
515
|
-
await artifactStore.publishArtifact(generated, {
|
|
516
|
-
sourceRevision,
|
|
517
|
-
recordRevision: false,
|
|
518
|
-
});
|
|
519
|
-
// Retention is allowed to decline a newly generated object when older
|
|
520
|
-
// entries have earned higher read recency. That cache decision must not
|
|
521
|
-
// discard the result from the request that just paid to generate it.
|
|
522
|
-
const responseArtifact = mergeRoutePreviewArtifactV3(
|
|
523
|
-
before.bundle,
|
|
524
|
-
generated,
|
|
525
|
-
);
|
|
526
|
-
return selectRoutePreviewArtifactFrames(responseArtifact, wanted, {
|
|
527
|
-
includeStale: true,
|
|
528
|
-
sourceRevision,
|
|
529
|
-
});
|
|
530
657
|
}, priority),
|
|
531
658
|
);
|
|
532
659
|
}
|
|
@@ -542,6 +669,28 @@ export function pygmalionPreviewArtifactPlugin({
|
|
|
542
669
|
void artifactStore.applyRetention?.(undefined)?.catch?.(() => undefined);
|
|
543
670
|
server.middlewares.use(async (request, response, next) => {
|
|
544
671
|
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
672
|
+
if (url.pathname === progressEndpoint) {
|
|
673
|
+
if (request.method !== 'GET') {
|
|
674
|
+
response.setHeader('allow', 'GET');
|
|
675
|
+
sendJson(response, 405, {
|
|
676
|
+
ok: false,
|
|
677
|
+
error: 'method_not_allowed',
|
|
678
|
+
});
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
if (!isLocalRequestHost(request.headers.host)) {
|
|
682
|
+
sendJson(response, 403, {
|
|
683
|
+
ok: false,
|
|
684
|
+
error: 'local_host_required',
|
|
685
|
+
});
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
sendJson(response, 200, {
|
|
689
|
+
ok: true,
|
|
690
|
+
progress: readCaptureProgress(),
|
|
691
|
+
});
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
545
694
|
if (url.pathname !== endpoint) {
|
|
546
695
|
next();
|
|
547
696
|
return;
|
|
@@ -10,6 +10,13 @@ const DEFAULT_PING_TIMEOUT_MS = 10_000;
|
|
|
10
10
|
const MAX_RESTART_BACKOFF_MS = 8_000;
|
|
11
11
|
const STOP_KILL_GRACE_MS = 5_000;
|
|
12
12
|
const STDERR_TAIL_LIMIT = 8_000;
|
|
13
|
+
const PROGRESS_PHASES = new Set([
|
|
14
|
+
'queued',
|
|
15
|
+
'preparing',
|
|
16
|
+
'capturing',
|
|
17
|
+
'retrying',
|
|
18
|
+
'finalizing',
|
|
19
|
+
]);
|
|
13
20
|
|
|
14
21
|
function assertPositiveNumber(value, name, fallback) {
|
|
15
22
|
if (value == null) return fallback;
|
|
@@ -127,6 +134,34 @@ function workerFailure(worker, fallbackMessage) {
|
|
|
127
134
|
return new Error(tail ? `${fallbackMessage}\n${tail}` : fallbackMessage);
|
|
128
135
|
}
|
|
129
136
|
|
|
137
|
+
function normalizeProgressMessage(message) {
|
|
138
|
+
if (message?.type !== 'progress' || !PROGRESS_PHASES.has(message.phase)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const completed = Number(message.completed);
|
|
142
|
+
const total = Number(message.total);
|
|
143
|
+
if (
|
|
144
|
+
!Number.isInteger(completed) ||
|
|
145
|
+
!Number.isInteger(total) ||
|
|
146
|
+
completed < 0 ||
|
|
147
|
+
total < 0 ||
|
|
148
|
+
completed > total
|
|
149
|
+
) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
phase: message.phase,
|
|
154
|
+
completed,
|
|
155
|
+
total,
|
|
156
|
+
...(typeof message.frameId === 'string' && message.frameId.trim()
|
|
157
|
+
? { frameId: message.frameId.trim() }
|
|
158
|
+
: {}),
|
|
159
|
+
...(typeof message.captureStatus === 'string' && message.captureStatus.trim()
|
|
160
|
+
? { captureStatus: message.captureStatus.trim() }
|
|
161
|
+
: {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
130
165
|
/**
|
|
131
166
|
* Long-lived capture worker channel: one child process that keeps its expensive
|
|
132
167
|
* boot (runtime preflight, case bundling, browser launch) warm across artifact
|
|
@@ -134,6 +169,7 @@ function workerFailure(worker, fallbackMessage) {
|
|
|
134
169
|
*
|
|
135
170
|
* Protocol: the channel writes `{ id, type: 'capture', payload }` — payload is
|
|
136
171
|
* `{ namespace, sourceRevision, frames?, captureBaseUrl? }` — and the worker
|
|
172
|
+
* may emit `{ id, type: 'progress', phase, completed, total, frameId? }`, then
|
|
137
173
|
* answers `{ id, ok: true, artifactFile }` with the bundle in a temp file the
|
|
138
174
|
* channel reads, validates, and deletes, or `{ id, ok: false, error }`.
|
|
139
175
|
* `{ id, type: 'ping' }` must be answered immediately even while booting; the
|
|
@@ -266,6 +302,17 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
266
302
|
settlePending(current, ({ resolve }) => resolve(message));
|
|
267
303
|
return;
|
|
268
304
|
}
|
|
305
|
+
if (message.type === 'progress') {
|
|
306
|
+
const progress = normalizeProgressMessage(message);
|
|
307
|
+
if (progress && pending.onProgress) {
|
|
308
|
+
try {
|
|
309
|
+
pending.onProgress(progress);
|
|
310
|
+
} catch {
|
|
311
|
+
// Progress observation must never fail the capture it observes.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
269
316
|
if (message.ok === true) {
|
|
270
317
|
settlePending(current, ({ resolve }) => resolve(message));
|
|
271
318
|
} else {
|
|
@@ -324,7 +371,11 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
324
371
|
current.proc.kill('SIGKILL');
|
|
325
372
|
}
|
|
326
373
|
|
|
327
|
-
function dispatch(
|
|
374
|
+
function dispatch(
|
|
375
|
+
current,
|
|
376
|
+
message,
|
|
377
|
+
{ kind, timeoutMs, onTimeout, onProgress },
|
|
378
|
+
) {
|
|
328
379
|
return new Promise((resolve, reject) => {
|
|
329
380
|
const timer = setTimeout(() => {
|
|
330
381
|
current.pending = null;
|
|
@@ -339,7 +390,14 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
339
390
|
);
|
|
340
391
|
}, timeoutMs);
|
|
341
392
|
timer.unref?.();
|
|
342
|
-
current.pending = {
|
|
393
|
+
current.pending = {
|
|
394
|
+
id: message.id,
|
|
395
|
+
kind,
|
|
396
|
+
resolve,
|
|
397
|
+
reject,
|
|
398
|
+
timer,
|
|
399
|
+
onProgress,
|
|
400
|
+
};
|
|
343
401
|
try {
|
|
344
402
|
current.proc.stdin.write(`${JSON.stringify(message)}\n`);
|
|
345
403
|
} catch (error) {
|
|
@@ -400,13 +458,14 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
400
458
|
return worker;
|
|
401
459
|
}
|
|
402
460
|
|
|
403
|
-
function runWorkerJob(current, payload) {
|
|
461
|
+
function runWorkerJob(current, payload, onProgress) {
|
|
404
462
|
return dispatch(
|
|
405
463
|
current,
|
|
406
464
|
{ id: nextRequestId(), type: 'capture', payload },
|
|
407
465
|
{
|
|
408
466
|
kind: 'capture',
|
|
409
467
|
timeoutMs: resolvedJobTimeoutMs,
|
|
468
|
+
onProgress,
|
|
410
469
|
onTimeout: () => {
|
|
411
470
|
recordCrash();
|
|
412
471
|
killWorker(current);
|
|
@@ -419,7 +478,7 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
419
478
|
* Degraded path: one spawn per request with `--once`, the same protocol over
|
|
420
479
|
* the child's own stdio. Slow again, but on-demand capture keeps answering.
|
|
421
480
|
*/
|
|
422
|
-
function runOnceJob(payload) {
|
|
481
|
+
function runOnceJob(payload, onProgress) {
|
|
423
482
|
return new Promise((resolve, reject) => {
|
|
424
483
|
const proc = spawn(
|
|
425
484
|
process.execPath,
|
|
@@ -450,6 +509,17 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
450
509
|
const id = nextRequestId();
|
|
451
510
|
attachLineParser(proc.stdout, (message) => {
|
|
452
511
|
if (message.id !== id) return;
|
|
512
|
+
if (message.type === 'progress') {
|
|
513
|
+
const progress = normalizeProgressMessage(message);
|
|
514
|
+
if (progress && onProgress) {
|
|
515
|
+
try {
|
|
516
|
+
onProgress(progress);
|
|
517
|
+
} catch {
|
|
518
|
+
// Progress observation must never fail the capture it observes.
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
453
523
|
if (message.ok === true) {
|
|
454
524
|
settle(() => resolve(message));
|
|
455
525
|
} else {
|
|
@@ -493,10 +563,13 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
493
563
|
});
|
|
494
564
|
}
|
|
495
565
|
|
|
496
|
-
async function generateArtifact(request) {
|
|
566
|
+
async function generateArtifact(request, { onProgress } = {}) {
|
|
497
567
|
if (disposed) {
|
|
498
568
|
throw new Error('Preview capture worker channel is disposed.');
|
|
499
569
|
}
|
|
570
|
+
if (onProgress != null && typeof onProgress !== 'function') {
|
|
571
|
+
throw new TypeError('Preview capture worker onProgress must be a function.');
|
|
572
|
+
}
|
|
500
573
|
// The artifact plugin serializes per identity; overlap here means a caller
|
|
501
574
|
// bypassed that queue, and interleaving two captures on one warm browser
|
|
502
575
|
// would let them poison each other.
|
|
@@ -509,8 +582,8 @@ export function createPreviewCaptureWorkerChannel({
|
|
|
509
582
|
try {
|
|
510
583
|
const current = degraded ? null : await ensureWorker();
|
|
511
584
|
const response = current
|
|
512
|
-
? await runWorkerJob(current, payload)
|
|
513
|
-
: await runOnceJob(payload);
|
|
585
|
+
? await runWorkerJob(current, payload, onProgress)
|
|
586
|
+
: await runOnceJob(payload, onProgress);
|
|
514
587
|
return await consumeArtifactFile(response);
|
|
515
588
|
} finally {
|
|
516
589
|
inFlight = false;
|
|
@@ -10,7 +10,7 @@ const AUTO_ID_PATTERN =
|
|
|
10
10
|
const TRANSIENT_ATTRIBUTE_PATTERN =
|
|
11
11
|
/\s(?:data-pygmalion-source|data-pygmalion-slot-count|data-vite-dev-id|data-reactid|data-reactroot|nonce)=(?:"[^"]*"|'[^']*')/gi;
|
|
12
12
|
const FROZEN_STYLE_PATTERN =
|
|
13
|
-
/<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{animation:none!important;transition:none!important;caret-color:transparent!important\}html,body\{pointer-events:none!important\}
|
|
13
|
+
/<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{(?:animation:none|animation-play-state:paused)!important;transition:none!important;caret-color:transparent!important\}(?:html,body\{pointer-events:none!important\})?\s*<\/style>/gi;
|
|
14
14
|
|
|
15
15
|
function safeSnapshot(snapshot) {
|
|
16
16
|
if (
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { annotateStoryboardAutomaticMotionTargets } from './automatic-motion-runtime.mjs';
|
|
2
3
|
import { annotateStoryboardAutomaticPseudoStates } from './automatic-pseudo-runtime.mjs';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 800 });
|
|
5
6
|
const QA_FAILURE_STAGES = new Set(['interaction', 'assertion']);
|
|
6
7
|
const STORYBOARD_ENVIRONMENT_QUERY = '__pygmalion_environment';
|
|
7
8
|
const FROZEN_STYLE =
|
|
8
|
-
'*,*::before,*::after{animation:
|
|
9
|
+
'*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}';
|
|
9
10
|
const DOM_STABLE_ATTRIBUTES = new Set([
|
|
10
11
|
'type',
|
|
11
12
|
'name',
|
|
@@ -93,6 +94,27 @@ function throwIfAborted(signal) {
|
|
|
93
94
|
}
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
function resetStoryboardAnimations() {
|
|
98
|
+
for (const animation of document.getAnimations?.({ subtree: true }) ?? []) {
|
|
99
|
+
try {
|
|
100
|
+
animation.pause();
|
|
101
|
+
animation.currentTime = 0;
|
|
102
|
+
} catch {
|
|
103
|
+
// An animation owned by an unavailable timeline remains CSS-paused.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function freezeStoryboardMotion(page, addStyle = true) {
|
|
109
|
+
if (addStyle) {
|
|
110
|
+
const style = await page.addStyleTag({ content: FROZEN_STYLE });
|
|
111
|
+
await style?.evaluate?.((element) => {
|
|
112
|
+
element.setAttribute('data-pygmalion-preview', 'frozen');
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
await page.evaluate(resetStoryboardAnimations);
|
|
116
|
+
}
|
|
117
|
+
|
|
96
118
|
async function atCaptureStage(stage, task, details = {}) {
|
|
97
119
|
try {
|
|
98
120
|
return await task();
|
|
@@ -1186,7 +1208,7 @@ export function serializeStoryboardPreviewDocument(
|
|
|
1186
1208
|
const frozenStyle = document.createElement('style');
|
|
1187
1209
|
frozenStyle.setAttribute('data-pygmalion-preview', 'frozen');
|
|
1188
1210
|
frozenStyle.textContent =
|
|
1189
|
-
'*,*::before,*::after{animation:
|
|
1211
|
+
'*,*::before,*::after{animation-play-state:paused!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
|
|
1190
1212
|
head.append(frozenStyle);
|
|
1191
1213
|
|
|
1192
1214
|
return `<!doctype html>${clone.outerHTML}`;
|
|
@@ -1243,7 +1265,7 @@ async function collectStableEvidence(
|
|
|
1243
1265
|
const evidence = {};
|
|
1244
1266
|
const errors = [];
|
|
1245
1267
|
try {
|
|
1246
|
-
if (!frozenStyleApplied) await page
|
|
1268
|
+
if (!frozenStyleApplied) await freezeStoryboardMotion(page);
|
|
1247
1269
|
if (!alreadyStable) {
|
|
1248
1270
|
const stable = await waitForStableStoryboardDocument(page, stability);
|
|
1249
1271
|
if (!stable) {
|
|
@@ -1253,9 +1275,10 @@ async function collectStableEvidence(
|
|
|
1253
1275
|
} catch (error) {
|
|
1254
1276
|
errors.push(new StoryboardCaptureStageError('stabilize', error));
|
|
1255
1277
|
}
|
|
1256
|
-
if (includePreviewSnapshot) {
|
|
1278
|
+
if (includePreviewSnapshot || includeDomTree) {
|
|
1257
1279
|
try {
|
|
1258
1280
|
await page.evaluate(annotateStoryboardAutomaticPseudoStates);
|
|
1281
|
+
await page.evaluate(annotateStoryboardAutomaticMotionTargets);
|
|
1259
1282
|
} catch (error) {
|
|
1260
1283
|
errors.push(new StoryboardCaptureStageError('serialize', error));
|
|
1261
1284
|
}
|
|
@@ -1491,10 +1514,8 @@ export async function captureStoryboardCase({
|
|
|
1491
1514
|
);
|
|
1492
1515
|
}
|
|
1493
1516
|
await atCaptureStage('stabilize', async () => {
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
if (session) session.frozen = true;
|
|
1497
|
-
}
|
|
1517
|
+
await freezeStoryboardMotion(page, !session?.frozen);
|
|
1518
|
+
if (session) session.frozen = true;
|
|
1498
1519
|
frozenStyleApplied = true;
|
|
1499
1520
|
const stable = await waitForStableStoryboardDocument(page, stability);
|
|
1500
1521
|
if (!stable) {
|
package/node/vite.mjs
CHANGED
|
@@ -67,6 +67,7 @@ import { resolvePreviewRouteDependencyDigest } from './route-dependency-digest.m
|
|
|
67
67
|
import {
|
|
68
68
|
DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
|
|
69
69
|
PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
|
|
70
|
+
PYGMALION_PREVIEW_CAPTURE_PROGRESS_SUFFIX,
|
|
70
71
|
pygmalionPreviewArtifactPlugin,
|
|
71
72
|
} from './preview-artifact-plugin.mjs';
|
|
72
73
|
import { createPreviewCaptureWorkerChannel } from './preview-capture-worker.mjs';
|
|
@@ -433,6 +434,7 @@ export {
|
|
|
433
434
|
validateRoutePreviewArtifactV3,
|
|
434
435
|
DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
|
|
435
436
|
PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
|
|
437
|
+
PYGMALION_PREVIEW_CAPTURE_PROGRESS_SUFFIX,
|
|
436
438
|
pygmalionPreviewArtifactPlugin,
|
|
437
439
|
createPreviewCaptureWorkerChannel,
|
|
438
440
|
DEFAULT_QA_CAPTURE_MAX_FRAMES,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pygmalionjs/pygmalion",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Code-backed DOM design sandbox and visual QA editor",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"publishConfig": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"dist-lib",
|
|
35
35
|
"docs/screen-state-contract.md",
|
|
36
36
|
"node/component-branches.mjs",
|
|
37
|
+
"node/automatic-motion-runtime.mjs",
|
|
37
38
|
"node/automatic-pseudo-runtime.mjs",
|
|
38
39
|
"node/design-session.mjs",
|
|
39
40
|
"node/dev-mirror.mjs",
|