@pygmalionjs/pygmalion 0.6.2 → 0.6.4

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.
@@ -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;
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.2",
3
+ "version": "0.6.4",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
package/vite.d.ts CHANGED
@@ -71,6 +71,18 @@ export interface PygmalionPreviewCaptureWorkerConfig {
71
71
  jobTimeoutMs?: number;
72
72
  }
73
73
 
74
+ export interface PygmalionPreviewCaptureProgressEvent {
75
+ phase: 'queued' | 'preparing' | 'capturing' | 'retrying' | 'finalizing';
76
+ completed: number;
77
+ total: number;
78
+ frameId?: string;
79
+ captureStatus?: string;
80
+ }
81
+
82
+ export interface PygmalionPreviewCaptureGeneratorOptions {
83
+ onProgress?: (progress: PygmalionPreviewCaptureProgressEvent) => void;
84
+ }
85
+
74
86
  export interface PygmalionPreviewConfig {
75
87
  configFile?: string;
76
88
  viteConfig?: string;
@@ -88,12 +100,15 @@ export interface PygmalionPreviewConfig {
88
100
  sessionPort?: number;
89
101
  artifactFile?: string;
90
102
  artifactEndpoint?: string;
91
- generateArtifact?: (request: {
92
- namespace: string;
93
- sourceRevision: string;
94
- frames?: readonly { id: string; fingerprint?: string }[];
95
- captureBaseUrl?: string;
96
- }) => unknown | Promise<unknown>;
103
+ generateArtifact?: (
104
+ request: {
105
+ namespace: string;
106
+ sourceRevision: string;
107
+ frames?: readonly { id: string; fingerprint?: string }[];
108
+ captureBaseUrl?: string;
109
+ },
110
+ options?: PygmalionPreviewCaptureGeneratorOptions,
111
+ ) => unknown | Promise<unknown>;
97
112
  /**
98
113
  * Warm capture worker used to satisfy artifact generation requests when
99
114
  * `generateArtifact` is absent. When both are set, `generateArtifact` wins.
@@ -170,18 +185,22 @@ export declare function createPygmalionVitePlugins(
170
185
  ): PluginOption[];
171
186
 
172
187
  export declare const PYGMALION_PREVIEW_ARTIFACT_ENDPOINT: string;
188
+ export declare const PYGMALION_PREVIEW_CAPTURE_PROGRESS_SUFFIX: string;
173
189
  export declare const DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE: string;
174
190
  export declare function pygmalionPreviewArtifactPlugin(options?: {
175
191
  root?: string;
176
192
  artifactFile?: string;
177
193
  endpoint?: string;
178
194
  disabled?: () => boolean;
179
- generateArtifact?: (request: {
180
- namespace: string;
181
- sourceRevision: string;
182
- frames?: readonly { id: string; fingerprint?: string }[];
183
- captureBaseUrl?: string;
184
- }) => unknown | Promise<unknown>;
195
+ generateArtifact?: (
196
+ request: {
197
+ namespace: string;
198
+ sourceRevision: string;
199
+ frames?: readonly { id: string; fingerprint?: string }[];
200
+ captureBaseUrl?: string;
201
+ },
202
+ options?: PygmalionPreviewCaptureGeneratorOptions,
203
+ ) => unknown | Promise<unknown>;
185
204
  acquireLease?: (
186
205
  reason: string,
187
206
  ) =>
@@ -190,12 +209,15 @@ export declare function pygmalionPreviewArtifactPlugin(options?: {
190
209
  | Promise<{ release(): void | Promise<void> } | null>;
191
210
  }): PluginOption;
192
211
  export interface PygmalionPreviewCaptureWorkerChannel {
193
- generateArtifact(request: {
194
- namespace: string;
195
- sourceRevision: string;
196
- frames?: readonly { id: string; fingerprint?: string }[];
197
- captureBaseUrl?: string;
198
- }): Promise<unknown>;
212
+ generateArtifact(
213
+ request: {
214
+ namespace: string;
215
+ sourceRevision: string;
216
+ frames?: readonly { id: string; fingerprint?: string }[];
217
+ captureBaseUrl?: string;
218
+ },
219
+ options?: PygmalionPreviewCaptureGeneratorOptions,
220
+ ): Promise<unknown>;
199
221
  dispose(): Promise<void>;
200
222
  }
201
223
  export declare function createPreviewCaptureWorkerChannel(