@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.
@@ -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
- return pending.map(({ element, identity, baseLabel, states }) => {
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 generated = await withCaptureLease(sourceRevision, () =>
450
- generateArtifact({
451
- namespace,
452
- sourceRevision,
453
- ...(captureBaseUrl ? { captureBaseUrl } : {}),
454
- }),
455
- );
456
- const validation = validateRoutePreviewArtifactBundle(generated);
457
- if (!validation.valid) {
458
- throw badGeneratorOutput('Generated preview artifact is invalid.');
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
- const generated = await withCaptureLease(sourceRevision, () =>
499
- generateArtifact({
500
- namespace,
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
- frames: remaining,
503
- ...(captureBaseUrl ? { captureBaseUrl } : {}),
504
- }),
505
- );
506
- const validation = validateRoutePreviewArtifactBundle(generated);
507
- if (!validation.valid || generated.version !== 3) {
508
- throw new Error('Generated preview artifact is invalid.');
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(current, message, { kind, timeoutMs, onTimeout }) {
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 = { id: message.id, kind, resolve, reject, timer };
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\}\s*<\/style>/gi;
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:none!important;transition:none!important;caret-color:transparent!important}';
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:none!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
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.addStyleTag({ content: FROZEN_STYLE });
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
- if (!session?.frozen) {
1495
- await page.addStyleTag({ content: FROZEN_STYLE });
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.1",
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",