@pygmalionjs/pygmalion 0.2.11 → 0.2.12

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.
@@ -0,0 +1,1226 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import pixelmatch from 'pixelmatch';
7
+ import pngjs from 'pngjs';
8
+
9
+ export const PYGMALION_QA_CAPTURE_ENDPOINT = '/__pygmalion-qa/capture';
10
+ export const PYGMALION_QA_ARTIFACT_PREFIX = '/__pygmalion-qa/artifacts/';
11
+ export const DEFAULT_QA_CAPTURE_MAX_FRAMES = 128;
12
+
13
+ const DEFAULT_TIMEOUT_MS = 180_000;
14
+ const DEFAULT_MAX_BASELINES = 8;
15
+ const DEFAULT_BASELINE_TTL_MS = 15 * 60_000;
16
+ const DEFAULT_MAX_BASELINE_BYTES = 128 * 1024 * 1024;
17
+ const DEFAULT_MAX_ARTIFACTS = 384;
18
+ const DEFAULT_MAX_ARTIFACT_BYTES = 128 * 1024 * 1024;
19
+ const DEFAULT_ARTIFACT_TTL_MS = 15 * 60_000;
20
+ const DEFAULT_MAX_PNG_BYTES = 16 * 1024 * 1024;
21
+ const DEFAULT_MAX_PNG_PIXELS = 16_777_216;
22
+ const DEFAULT_PIXEL_THRESHOLD = 0.1;
23
+ const MAX_BODY_BYTES = 32 * 1024;
24
+ const FRAME_ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_-]{0,119}$/;
25
+ const BASELINE_ID_RE = /^[A-Za-z0-9_-]{8,80}$/;
26
+ const ARTIFACT_ID_RE = /^[A-Za-z0-9_-]{8,80}$/;
27
+ const SCREENSHOT_FILE_RE = /^[0-9a-f]{64}\.png$/;
28
+ const HASH_RE = /^[0-9a-f]{64}$/;
29
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
30
+ const { PNG } = pngjs;
31
+ const DEFAULT_ROOT = process.cwd();
32
+
33
+ export class QaCaptureError extends Error {
34
+ constructor(statusCode, code, message, failures) {
35
+ super(message);
36
+ this.name = 'QaCaptureError';
37
+ this.statusCode = statusCode;
38
+ this.code = code;
39
+ this.failures = failures;
40
+ }
41
+ }
42
+
43
+ function requestHost(value) {
44
+ const raw = Array.isArray(value) ? value[0] : value;
45
+ if (typeof raw !== 'string' || !raw.trim()) return null;
46
+ try {
47
+ return new URL(`http://${raw.trim()}`).host.toLowerCase();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function isLocalRequestHost(value) {
54
+ const host = requestHost(value);
55
+ if (!host) return false;
56
+ try {
57
+ return ['localhost', '127.0.0.1', '[::1]', '::1'].includes(
58
+ new URL(`http://${host}`).hostname.toLowerCase(),
59
+ );
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ function artifactUrl(id) {
66
+ return `${PYGMALION_QA_ARTIFACT_PREFIX}${id}.png`;
67
+ }
68
+
69
+ export function normalizeLocalCaptureBasePath(value, allowedHost) {
70
+ if (
71
+ typeof value !== 'string' ||
72
+ value.trim() !== value ||
73
+ value.length < 1 ||
74
+ value.length > 2_048
75
+ ) {
76
+ throw new QaCaptureError(400, 'invalid_base_path', 'basePath is invalid.');
77
+ }
78
+ let url;
79
+ try {
80
+ url = new URL(value);
81
+ } catch {
82
+ throw new QaCaptureError(
83
+ 400,
84
+ 'invalid_base_path',
85
+ 'basePath must be an absolute local URL.',
86
+ );
87
+ }
88
+ const hostname = url.hostname.toLowerCase();
89
+ if (
90
+ !['http:', 'https:'].includes(url.protocol) ||
91
+ !['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname) ||
92
+ url.username ||
93
+ url.password ||
94
+ url.search ||
95
+ url.hash
96
+ ) {
97
+ throw new QaCaptureError(
98
+ 400,
99
+ 'invalid_base_path',
100
+ 'basePath must be a local Vite URL without a query or hash.',
101
+ );
102
+ }
103
+ const expectedHost = requestHost(allowedHost);
104
+ if (expectedHost && url.host.toLowerCase() !== expectedHost) {
105
+ throw new QaCaptureError(
106
+ 400,
107
+ 'invalid_base_path',
108
+ 'basePath must use the same host as the current Vite server.',
109
+ );
110
+ }
111
+
112
+ const authorityStart = value.indexOf('//') + 2;
113
+ const rawPathStart = value.indexOf('/', authorityStart);
114
+ const rawPath = rawPathStart >= 0 ? value.slice(rawPathStart) : '/';
115
+ let decodedPath;
116
+ try {
117
+ decodedPath = decodeURIComponent(rawPath);
118
+ } catch {
119
+ throw new QaCaptureError(
120
+ 400,
121
+ 'invalid_base_path',
122
+ 'basePath encoding is invalid.',
123
+ );
124
+ }
125
+ if (
126
+ decodedPath.includes('\0') ||
127
+ decodedPath.includes('\\') ||
128
+ decodedPath.split('/').some((segment) => segment === '.' || segment === '..')
129
+ ) {
130
+ throw new QaCaptureError(
131
+ 400,
132
+ 'invalid_base_path',
133
+ 'basePath contains an unsafe path.',
134
+ );
135
+ }
136
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/`;
137
+ return url.href;
138
+ }
139
+
140
+ export function validateQaCaptureRequest(
141
+ value,
142
+ { allowedHost, maxFrameIds = DEFAULT_QA_CAPTURE_MAX_FRAMES } = {},
143
+ ) {
144
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
145
+ throw new QaCaptureError(
146
+ 400,
147
+ 'invalid_request',
148
+ 'A JSON object request is required.',
149
+ );
150
+ }
151
+ if (!['baseline', 'changed'].includes(value.phase)) {
152
+ throw new QaCaptureError(
153
+ 400,
154
+ 'invalid_phase',
155
+ 'phase must be baseline or changed.',
156
+ );
157
+ }
158
+ if (!Number.isInteger(maxFrameIds) || maxFrameIds < 1) {
159
+ throw new Error('maxFrameIds must be a positive integer');
160
+ }
161
+ if (
162
+ !Array.isArray(value.frameIds) ||
163
+ value.frameIds.length < 1 ||
164
+ value.frameIds.length > maxFrameIds ||
165
+ value.frameIds.some((id) => typeof id !== 'string' || !FRAME_ID_RE.test(id)) ||
166
+ new Set(value.frameIds).size !== value.frameIds.length
167
+ ) {
168
+ throw new QaCaptureError(
169
+ 400,
170
+ 'invalid_frame_ids',
171
+ `frameIds must contain at most ${maxFrameIds} unique valid IDs.`,
172
+ );
173
+ }
174
+ if (value.phase === 'baseline' && value.baselineId != null) {
175
+ throw new QaCaptureError(
176
+ 400,
177
+ 'invalid_baseline_id',
178
+ 'The baseline phase must not include baselineId.',
179
+ );
180
+ }
181
+ if (
182
+ value.phase === 'changed' &&
183
+ (typeof value.baselineId !== 'string' || !BASELINE_ID_RE.test(value.baselineId))
184
+ ) {
185
+ throw new QaCaptureError(
186
+ 400,
187
+ 'invalid_baseline_id',
188
+ 'The changed phase requires a valid baselineId.',
189
+ );
190
+ }
191
+ return {
192
+ phase: value.phase,
193
+ basePath: normalizeLocalCaptureBasePath(value.basePath, allowedHost),
194
+ frameIds: [...value.frameIds],
195
+ ...(value.phase === 'changed' ? { baselineId: value.baselineId } : {}),
196
+ };
197
+ }
198
+
199
+ async function readManifest(file) {
200
+ try {
201
+ return JSON.parse(await fs.readFile(file, 'utf8'));
202
+ } catch (error) {
203
+ if (error?.code === 'ENOENT') return null;
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ function terminateChild(child) {
209
+ if (!child || child.exitCode != null || child.signalCode != null) return;
210
+ child.kill('SIGTERM');
211
+ const force = setTimeout(() => {
212
+ if (child.exitCode == null && child.signalCode == null) child.kill('SIGKILL');
213
+ }, 2_000);
214
+ force.unref?.();
215
+ }
216
+
217
+ export async function runCaptureScript({
218
+ root = DEFAULT_ROOT,
219
+ scriptPath,
220
+ basePath,
221
+ frameIds,
222
+ timeoutMs = DEFAULT_TIMEOUT_MS,
223
+ maxPngBytes = DEFAULT_MAX_PNG_BYTES,
224
+ maxTotalPngBytes = DEFAULT_MAX_BASELINE_BYTES,
225
+ signal,
226
+ }) {
227
+ if (typeof scriptPath !== 'string' || scriptPath.trim() === '') {
228
+ throw new Error('QA capture scriptPath is required.');
229
+ }
230
+ const validated = validateQaCaptureRequest({
231
+ phase: 'baseline',
232
+ basePath,
233
+ frameIds,
234
+ });
235
+ if (
236
+ !Number.isInteger(timeoutMs) ||
237
+ timeoutMs < 1 ||
238
+ !Number.isInteger(maxPngBytes) ||
239
+ maxPngBytes < 1 ||
240
+ !Number.isInteger(maxTotalPngBytes) ||
241
+ maxTotalPngBytes < 1
242
+ ) {
243
+ throw new Error('Invalid capture process limits');
244
+ }
245
+ if (signal?.aborted) {
246
+ throw new QaCaptureError(499, 'capture_aborted', 'QA capture was aborted.');
247
+ }
248
+ const temporaryRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pygmalion-qa-capture-'));
249
+ const output = path.join(temporaryRoot, 'capture.json');
250
+ const failedOutput = `${output}.failed.json`;
251
+ const screenshotDirectory = path.join(temporaryRoot, 'screenshots');
252
+ let child = null;
253
+ let timer = null;
254
+ let aborted = false;
255
+ let timedOut = false;
256
+ let tail = '';
257
+ const onAbort = () => {
258
+ aborted = true;
259
+ terminateChild(child);
260
+ };
261
+
262
+ try {
263
+ const args = [
264
+ scriptPath,
265
+ '--base-url',
266
+ validated.basePath,
267
+ '--no-start',
268
+ '--out',
269
+ output,
270
+ '--screenshot-dir',
271
+ screenshotDirectory,
272
+ ];
273
+ for (const id of validated.frameIds) args.push('--case', id);
274
+ child = spawn(process.execPath, args, {
275
+ cwd: root,
276
+ env: { ...process.env, BROWSER: 'none' },
277
+ stdio: ['ignore', 'pipe', 'pipe'],
278
+ });
279
+ for (const stream of [child.stdout, child.stderr]) {
280
+ stream?.on('data', (chunk) => {
281
+ tail = `${tail}${String(chunk)}`.slice(-16_000);
282
+ });
283
+ }
284
+ signal?.addEventListener('abort', onAbort, { once: true });
285
+ timer = setTimeout(() => {
286
+ timedOut = true;
287
+ terminateChild(child);
288
+ }, timeoutMs);
289
+ timer.unref?.();
290
+
291
+ const exit = await new Promise((resolvePromise) => {
292
+ let settled = false;
293
+ const settle = (value) => {
294
+ if (settled) return;
295
+ settled = true;
296
+ resolvePromise(value);
297
+ };
298
+ child.once('error', (error) => settle({ code: null, error }));
299
+ child.once('exit', (code, exitSignal) => settle({ code, signal: exitSignal }));
300
+ });
301
+ if (timedOut) {
302
+ throw new QaCaptureError(
303
+ 504,
304
+ 'capture_timeout',
305
+ `QA capture did not finish within ${timeoutMs}ms.`,
306
+ );
307
+ }
308
+ if (aborted || signal?.aborted) {
309
+ throw new QaCaptureError(499, 'capture_aborted', 'QA capture was aborted.');
310
+ }
311
+
312
+ const manifest = (await readManifest(output)) ?? (await readManifest(failedOutput));
313
+ if (!manifest) {
314
+ const detail = exit.error?.message ?? tail.trim() ?? `exit ${exit.code ?? exit.signal ?? 'unknown'}`;
315
+ throw new QaCaptureError(
316
+ 502,
317
+ 'capture_process_failed',
318
+ `QA capture process failed: ${detail.slice(-1_000)}`,
319
+ );
320
+ }
321
+ const screens = [];
322
+ let totalPngBytes = 0;
323
+ for (const screen of Array.isArray(manifest.screens) ? manifest.screens : []) {
324
+ if (screen?.screenshotFile == null) {
325
+ screens.push(screen);
326
+ continue;
327
+ }
328
+ if (
329
+ typeof screen.screenshotFile !== 'string' ||
330
+ !SCREENSHOT_FILE_RE.test(screen.screenshotFile)
331
+ ) {
332
+ throw new QaCaptureError(
333
+ 502,
334
+ 'invalid_capture_output',
335
+ 'The QA capture PNG path is invalid.',
336
+ );
337
+ }
338
+ const screenshotPath = path.resolve(screenshotDirectory, screen.screenshotFile);
339
+ if (path.dirname(screenshotPath) !== path.resolve(screenshotDirectory)) {
340
+ throw new QaCaptureError(
341
+ 502,
342
+ 'invalid_capture_output',
343
+ 'The QA capture PNG path escapes its temporary directory.',
344
+ );
345
+ }
346
+ const screenshotStat = await fs.lstat(screenshotPath);
347
+ if (!screenshotStat.isFile() || screenshotStat.isSymbolicLink()) {
348
+ throw new QaCaptureError(
349
+ 502,
350
+ 'invalid_capture_output',
351
+ 'A QA capture PNG must be a regular file in its temporary directory.',
352
+ );
353
+ }
354
+ totalPngBytes += screenshotStat.size;
355
+ if (
356
+ screenshotStat.size > maxPngBytes ||
357
+ totalPngBytes > maxTotalPngBytes
358
+ ) {
359
+ throw new QaCaptureError(
360
+ 502,
361
+ 'capture_png_too_large',
362
+ 'QA capture PNG bytes exceed the individual or process limit.',
363
+ );
364
+ }
365
+ screens.push({
366
+ ...screen,
367
+ screenshotBytes: await fs.readFile(screenshotPath),
368
+ });
369
+ }
370
+ return {
371
+ screens,
372
+ failures: Array.isArray(manifest.failures) ? manifest.failures : [],
373
+ };
374
+ } finally {
375
+ if (timer) clearTimeout(timer);
376
+ signal?.removeEventListener('abort', onAbort);
377
+ terminateChild(child);
378
+ await fs.rm(temporaryRoot, { recursive: true, force: true });
379
+ }
380
+ }
381
+
382
+ class BaselineLru {
383
+ constructor({ maxEntries, maxBytes, ttlMs, now }) {
384
+ this.maxEntries = maxEntries;
385
+ this.maxBytes = maxBytes;
386
+ this.ttlMs = ttlMs;
387
+ this.now = now;
388
+ this.entries = new Map();
389
+ this.totalBytes = 0;
390
+ }
391
+
392
+ remove(id) {
393
+ const entry = this.entries.get(id);
394
+ if (!entry) return;
395
+ this.totalBytes -= entry.byteSize ?? 0;
396
+ this.entries.delete(id);
397
+ }
398
+
399
+ prune() {
400
+ const current = this.now();
401
+ for (const [id, entry] of this.entries) {
402
+ if (entry.expiresAt <= current) this.remove(id);
403
+ }
404
+ }
405
+
406
+ set(id, value) {
407
+ this.prune();
408
+ this.remove(id);
409
+ this.entries.set(id, { ...value, expiresAt: this.now() + this.ttlMs });
410
+ this.totalBytes += value.byteSize ?? 0;
411
+ while (this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes) {
412
+ this.remove(this.entries.keys().next().value);
413
+ }
414
+ }
415
+
416
+ get(id) {
417
+ this.prune();
418
+ const entry = this.entries.get(id);
419
+ if (!entry) return null;
420
+ this.entries.delete(id);
421
+ const touched = { ...entry, expiresAt: this.now() + this.ttlMs };
422
+ this.entries.set(id, touched);
423
+ return touched;
424
+ }
425
+
426
+ getArtifact(id) {
427
+ this.prune();
428
+ for (const [baselineId, entry] of this.entries) {
429
+ for (const frame of entry.frames.values()) {
430
+ if (frame.screenshotArtifactId === id && frame.screenshotBytes) {
431
+ this.entries.delete(baselineId);
432
+ this.entries.set(baselineId, {
433
+ ...entry,
434
+ expiresAt: this.now() + this.ttlMs,
435
+ });
436
+ return frame.screenshotBytes;
437
+ }
438
+ }
439
+ }
440
+ return null;
441
+ }
442
+
443
+ hasArtifact(id) {
444
+ this.prune();
445
+ for (const entry of this.entries.values()) {
446
+ for (const frame of entry.frames.values()) {
447
+ if (frame.screenshotArtifactId === id) return true;
448
+ }
449
+ }
450
+ return false;
451
+ }
452
+
453
+ get size() {
454
+ this.prune();
455
+ return this.entries.size;
456
+ }
457
+ }
458
+
459
+ class ArtifactLru {
460
+ constructor({ maxEntries, maxBytes, ttlMs, now }) {
461
+ this.maxEntries = maxEntries;
462
+ this.maxBytes = maxBytes;
463
+ this.ttlMs = ttlMs;
464
+ this.now = now;
465
+ this.entries = new Map();
466
+ this.totalBytes = 0;
467
+ }
468
+
469
+ remove(id) {
470
+ const entry = this.entries.get(id);
471
+ if (!entry) return;
472
+ this.totalBytes -= entry.bytes.length;
473
+ this.entries.delete(id);
474
+ }
475
+
476
+ prune() {
477
+ const current = this.now();
478
+ for (const [id, entry] of this.entries) {
479
+ if (entry.expiresAt <= current) this.remove(id);
480
+ }
481
+ }
482
+
483
+ setBatch(artifacts) {
484
+ this.prune();
485
+ const ids = new Set();
486
+ let bytes = 0;
487
+ for (const artifact of artifacts) {
488
+ if (
489
+ !artifact ||
490
+ typeof artifact.id !== 'string' ||
491
+ !ARTIFACT_ID_RE.test(artifact.id) ||
492
+ !Buffer.isBuffer(artifact.bytes) ||
493
+ ids.has(artifact.id)
494
+ ) {
495
+ throw new Error('Invalid QA review artifact');
496
+ }
497
+ ids.add(artifact.id);
498
+ bytes += artifact.bytes.length;
499
+ }
500
+ if (artifacts.length > this.maxEntries || bytes > this.maxBytes) return false;
501
+ for (const id of ids) this.remove(id);
502
+ while (
503
+ this.entries.size + artifacts.length > this.maxEntries ||
504
+ this.totalBytes + bytes > this.maxBytes
505
+ ) {
506
+ this.remove(this.entries.keys().next().value);
507
+ }
508
+ const expiresAt = this.now() + this.ttlMs;
509
+ for (const artifact of artifacts) {
510
+ this.entries.set(artifact.id, {
511
+ bytes: artifact.bytes,
512
+ expiresAt,
513
+ });
514
+ this.totalBytes += artifact.bytes.length;
515
+ }
516
+ return true;
517
+ }
518
+
519
+ get(id) {
520
+ this.prune();
521
+ const entry = this.entries.get(id);
522
+ if (!entry) return null;
523
+ this.entries.delete(id);
524
+ const touched = { ...entry, expiresAt: this.now() + this.ttlMs };
525
+ this.entries.set(id, touched);
526
+ return touched.bytes;
527
+ }
528
+
529
+ has(id) {
530
+ this.prune();
531
+ return this.entries.has(id);
532
+ }
533
+
534
+ get size() {
535
+ this.prune();
536
+ return this.entries.size;
537
+ }
538
+ }
539
+
540
+ function inspectPng(bytes, { maxPngBytes, maxPngPixels }) {
541
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
542
+ if (
543
+ buffer.length < 24 ||
544
+ buffer.length > maxPngBytes ||
545
+ !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)
546
+ ) {
547
+ throw new Error(`PNG bytes are invalid or exceed the ${maxPngBytes} byte limit.`);
548
+ }
549
+ const width = buffer.readUInt32BE(16);
550
+ const height = buffer.readUInt32BE(20);
551
+ if (
552
+ width < 1 ||
553
+ height < 1 ||
554
+ width * height > maxPngPixels
555
+ ) {
556
+ throw new Error(`PNG dimensions exceed the ${maxPngPixels} pixel limit.`);
557
+ }
558
+ return { buffer, width, height };
559
+ }
560
+
561
+ function decodePng(bytes, limits) {
562
+ const inspected = inspectPng(bytes, limits);
563
+ const decoded = PNG.sync.read(inspected.buffer, { checkCRC: true });
564
+ if (
565
+ decoded.width !== inspected.width ||
566
+ decoded.height !== inspected.height ||
567
+ decoded.data.length !== decoded.width * decoded.height * 4
568
+ ) {
569
+ throw new Error('Decoded PNG dimensions are invalid.');
570
+ }
571
+ return decoded;
572
+ }
573
+
574
+ export function comparePngScreenshots(
575
+ beforeBytes,
576
+ afterBytes,
577
+ {
578
+ maxPngBytes = DEFAULT_MAX_PNG_BYTES,
579
+ maxPngPixels = DEFAULT_MAX_PNG_PIXELS,
580
+ threshold = DEFAULT_PIXEL_THRESHOLD,
581
+ } = {},
582
+ ) {
583
+ if (
584
+ !Number.isInteger(maxPngBytes) ||
585
+ maxPngBytes < 1 ||
586
+ !Number.isInteger(maxPngPixels) ||
587
+ maxPngPixels < 1 ||
588
+ typeof threshold !== 'number' ||
589
+ !Number.isFinite(threshold) ||
590
+ threshold < 0 ||
591
+ threshold > 1
592
+ ) {
593
+ throw new Error('Invalid PNG comparison limits');
594
+ }
595
+ const before = decodePng(beforeBytes, { maxPngBytes, maxPngPixels });
596
+ const after = decodePng(afterBytes, { maxPngBytes, maxPngPixels });
597
+ const overlapWidth = Math.min(before.width, after.width);
598
+ const overlapHeight = Math.min(before.height, after.height);
599
+ const overlapBefore = Buffer.alloc(overlapWidth * overlapHeight * 4);
600
+ const overlapAfter = Buffer.alloc(overlapWidth * overlapHeight * 4);
601
+ for (let y = 0; y < overlapHeight; y += 1) {
602
+ const beforeStart = y * before.width * 4;
603
+ const afterStart = y * after.width * 4;
604
+ const targetStart = y * overlapWidth * 4;
605
+ before.data.copy(
606
+ overlapBefore,
607
+ targetStart,
608
+ beforeStart,
609
+ beforeStart + overlapWidth * 4,
610
+ );
611
+ after.data.copy(
612
+ overlapAfter,
613
+ targetStart,
614
+ afterStart,
615
+ afterStart + overlapWidth * 4,
616
+ );
617
+ }
618
+
619
+ const overlapDiff = Buffer.alloc(overlapWidth * overlapHeight * 4);
620
+ const overlapChanged = pixelmatch(
621
+ overlapBefore,
622
+ overlapAfter,
623
+ overlapDiff,
624
+ overlapWidth,
625
+ overlapHeight,
626
+ {
627
+ threshold,
628
+ includeAA: true,
629
+ alpha: 0.25,
630
+ diffColor: [255, 0, 96],
631
+ diffColorAlt: [128, 0, 255],
632
+ },
633
+ );
634
+ const width = Math.max(before.width, after.width);
635
+ const height = Math.max(before.height, after.height);
636
+ const heatmap = new PNG({ width, height });
637
+ heatmap.data.fill(0);
638
+ for (let y = 0; y < overlapHeight; y += 1) {
639
+ const sourceStart = y * overlapWidth * 4;
640
+ overlapDiff.copy(
641
+ heatmap.data,
642
+ y * width * 4,
643
+ sourceStart,
644
+ sourceStart + overlapWidth * 4,
645
+ );
646
+ }
647
+
648
+ const overlapPixels = overlapWidth * overlapHeight;
649
+ const outsidePixels =
650
+ before.width * before.height + after.width * after.height - 2 * overlapPixels;
651
+ for (let y = 0; y < height; y += 1) {
652
+ for (let x = 0; x < width; x += 1) {
653
+ const inBefore = x < before.width && y < before.height;
654
+ const inAfter = x < after.width && y < after.height;
655
+ if (inBefore === inAfter) continue;
656
+ const offset = (y * width + x) * 4;
657
+ heatmap.data[offset] = 255;
658
+ heatmap.data[offset + 1] = 0;
659
+ heatmap.data[offset + 2] = 96;
660
+ heatmap.data[offset + 3] = 255;
661
+ }
662
+ }
663
+ const totalPixels =
664
+ before.width * before.height + after.width * after.height - overlapPixels;
665
+ const changedPixels = overlapChanged + outsidePixels;
666
+ return {
667
+ before: { width: before.width, height: before.height },
668
+ after: { width: after.width, height: after.height },
669
+ dimensionsMatch: before.width === after.width && before.height === after.height,
670
+ changedPixels,
671
+ totalPixels,
672
+ diffRatio: totalPixels === 0 ? 0 : changedPixels / totalPixels,
673
+ threshold,
674
+ heatmapBytes: PNG.sync.write(heatmap, { colorType: 6 }),
675
+ };
676
+ }
677
+
678
+ function normalizeCaptureResult(
679
+ frameIds,
680
+ result,
681
+ { maxPngBytes, maxPngPixels },
682
+ ) {
683
+ const requested = new Set(frameIds);
684
+ const frames = new Map();
685
+ for (const screen of result?.screens ?? []) {
686
+ if (
687
+ !screen ||
688
+ typeof screen.id !== 'string' ||
689
+ !requested.has(screen.id) ||
690
+ !HASH_RE.test(screen.domStructureHash) ||
691
+ !HASH_RE.test(screen.screenshotHash) ||
692
+ (screen.screenshotBytes != null &&
693
+ !Buffer.isBuffer(screen.screenshotBytes) &&
694
+ !(screen.screenshotBytes instanceof Uint8Array)) ||
695
+ frames.has(screen.id)
696
+ ) {
697
+ throw new QaCaptureError(
698
+ 502,
699
+ 'invalid_capture_output',
700
+ 'The QA capture result is invalid.',
701
+ );
702
+ }
703
+ let screenshotBytes;
704
+ let screenshotSize;
705
+ if (screen.screenshotBytes != null) {
706
+ try {
707
+ const inspected = inspectPng(screen.screenshotBytes, {
708
+ maxPngBytes,
709
+ maxPngPixels,
710
+ });
711
+ screenshotBytes = inspected.buffer;
712
+ screenshotSize = { width: inspected.width, height: inspected.height };
713
+ } catch (error) {
714
+ throw new QaCaptureError(
715
+ 502,
716
+ 'invalid_capture_png',
717
+ `The QA capture PNG is invalid: ${error instanceof Error ? error.message : error}`,
718
+ );
719
+ }
720
+ }
721
+ frames.set(screen.id, {
722
+ id: screen.id,
723
+ domStructureHash: screen.domStructureHash,
724
+ screenshotHash: screen.screenshotHash,
725
+ ...(screenshotBytes ? { screenshotBytes, screenshotSize } : {}),
726
+ });
727
+ }
728
+ const failures = [];
729
+ const failedIds = new Set();
730
+ for (const failure of result?.failures ?? []) {
731
+ if (
732
+ !failure ||
733
+ typeof failure.id !== 'string' ||
734
+ !requested.has(failure.id) ||
735
+ failedIds.has(failure.id)
736
+ ) {
737
+ throw new QaCaptureError(
738
+ 502,
739
+ 'invalid_capture_output',
740
+ 'The QA capture failure result is invalid.',
741
+ );
742
+ }
743
+ failedIds.add(failure.id);
744
+ failures.push({
745
+ id: failure.id,
746
+ error: String(failure.error ?? 'Screen capture failed.').slice(0, 1_000),
747
+ });
748
+ }
749
+ for (const id of frameIds) {
750
+ if (!frames.has(id) && !failedIds.has(id)) {
751
+ failedIds.add(id);
752
+ failures.push({ id, error: 'No screen capture result was returned.' });
753
+ }
754
+ }
755
+ return {
756
+ frames,
757
+ failures,
758
+ complete: failures.length === 0 && frames.size === frameIds.length,
759
+ };
760
+ }
761
+
762
+ async function withTimeout(captureRunner, input, timeoutMs) {
763
+ const controller = new AbortController();
764
+ let timer;
765
+ const timeout = new Promise((_, reject) => {
766
+ timer = setTimeout(() => {
767
+ controller.abort();
768
+ reject(
769
+ new QaCaptureError(
770
+ 504,
771
+ 'capture_timeout',
772
+ `QA capture did not finish within ${timeoutMs}ms.`,
773
+ ),
774
+ );
775
+ }, timeoutMs);
776
+ timer.unref?.();
777
+ });
778
+ const capture = Promise.resolve().then(() =>
779
+ captureRunner({ ...input, timeoutMs, signal: controller.signal }),
780
+ );
781
+ capture.catch(() => undefined);
782
+ try {
783
+ return await Promise.race([capture, timeout]);
784
+ } finally {
785
+ clearTimeout(timer);
786
+ }
787
+ }
788
+
789
+ function publicHashes(frame, screenshotUrl) {
790
+ return {
791
+ domStructureHash: frame.domStructureHash,
792
+ screenshotHash: frame.screenshotHash,
793
+ ...(screenshotUrl ? { screenshotUrl } : {}),
794
+ };
795
+ }
796
+
797
+ function publicFrame(frame) {
798
+ return {
799
+ id: frame.id,
800
+ ...publicHashes(
801
+ frame,
802
+ frame.screenshotArtifactId ? artifactUrl(frame.screenshotArtifactId) : null,
803
+ ),
804
+ };
805
+ }
806
+
807
+ export function createQaCaptureService({
808
+ root = DEFAULT_ROOT,
809
+ scriptPath,
810
+ captureRunner = runCaptureScript,
811
+ timeoutMs = DEFAULT_TIMEOUT_MS,
812
+ maxFrameIds = DEFAULT_QA_CAPTURE_MAX_FRAMES,
813
+ maxBaselines = DEFAULT_MAX_BASELINES,
814
+ baselineTtlMs = DEFAULT_BASELINE_TTL_MS,
815
+ maxBaselineBytes = DEFAULT_MAX_BASELINE_BYTES,
816
+ maxArtifacts = DEFAULT_MAX_ARTIFACTS,
817
+ maxArtifactBytes = DEFAULT_MAX_ARTIFACT_BYTES,
818
+ artifactTtlMs = DEFAULT_ARTIFACT_TTL_MS,
819
+ maxPngBytes = DEFAULT_MAX_PNG_BYTES,
820
+ maxPngPixels = DEFAULT_MAX_PNG_PIXELS,
821
+ pixelThreshold = DEFAULT_PIXEL_THRESHOLD,
822
+ now = () => Date.now(),
823
+ createBaselineId = () => randomUUID(),
824
+ createArtifactId = () => randomUUID(),
825
+ } = {}) {
826
+ if (
827
+ !Number.isInteger(timeoutMs) ||
828
+ timeoutMs < 1 ||
829
+ !Number.isInteger(maxFrameIds) ||
830
+ maxFrameIds < 1 ||
831
+ !Number.isInteger(maxBaselines) ||
832
+ maxBaselines < 1 ||
833
+ !Number.isInteger(baselineTtlMs) ||
834
+ baselineTtlMs < 1 ||
835
+ !Number.isInteger(maxBaselineBytes) ||
836
+ maxBaselineBytes < 1 ||
837
+ !Number.isInteger(maxArtifacts) ||
838
+ maxArtifacts < 1 ||
839
+ !Number.isInteger(maxArtifactBytes) ||
840
+ maxArtifactBytes < 1 ||
841
+ !Number.isInteger(artifactTtlMs) ||
842
+ artifactTtlMs < 1 ||
843
+ !Number.isInteger(maxPngBytes) ||
844
+ maxPngBytes < 1 ||
845
+ !Number.isInteger(maxPngPixels) ||
846
+ maxPngPixels < 1 ||
847
+ typeof pixelThreshold !== 'number' ||
848
+ !Number.isFinite(pixelThreshold) ||
849
+ pixelThreshold < 0 ||
850
+ pixelThreshold > 1
851
+ ) {
852
+ throw new Error('Invalid QA capture service limits');
853
+ }
854
+ const baselines = new BaselineLru({
855
+ maxEntries: maxBaselines,
856
+ maxBytes: maxBaselineBytes,
857
+ ttlMs: baselineTtlMs,
858
+ now,
859
+ });
860
+ const artifacts = new ArtifactLru({
861
+ maxEntries: maxArtifacts,
862
+ maxBytes: maxArtifactBytes,
863
+ ttlMs: artifactTtlMs,
864
+ now,
865
+ });
866
+ const nextArtifactId = (reserved = new Set()) => {
867
+ for (let attempt = 0; attempt < 4; attempt += 1) {
868
+ const id = createArtifactId();
869
+ if (
870
+ typeof id === 'string' &&
871
+ ARTIFACT_ID_RE.test(id) &&
872
+ !reserved.has(id) &&
873
+ !baselines.hasArtifact(id) &&
874
+ !artifacts.has(id)
875
+ ) {
876
+ reserved.add(id);
877
+ return id;
878
+ }
879
+ }
880
+ throw new Error('createArtifactId returned an invalid or duplicate ID');
881
+ };
882
+ let active = false;
883
+
884
+ const capture = async (rawRequest, { allowedHost } = {}) => {
885
+ const request = validateQaCaptureRequest(rawRequest, { allowedHost, maxFrameIds });
886
+ if (active) {
887
+ throw new QaCaptureError(
888
+ 409,
889
+ 'capture_in_progress',
890
+ 'Another QA capture is already running.',
891
+ );
892
+ }
893
+
894
+ let baseline = null;
895
+ if (request.phase === 'changed') {
896
+ baseline = baselines.get(request.baselineId);
897
+ if (!baseline) {
898
+ throw new QaCaptureError(
899
+ 404,
900
+ 'baseline_not_found',
901
+ 'The baseline does not exist or has expired.',
902
+ );
903
+ }
904
+ if (
905
+ baseline.frameIds.length !== request.frameIds.length ||
906
+ baseline.frameIds.some((id, index) => id !== request.frameIds[index])
907
+ ) {
908
+ throw new QaCaptureError(
909
+ 409,
910
+ 'baseline_mismatch',
911
+ 'The baseline frameIds do not match the current request.',
912
+ );
913
+ }
914
+ }
915
+
916
+ active = true;
917
+ try {
918
+ const runnerInput = {
919
+ root,
920
+ scriptPath,
921
+ basePath: request.basePath,
922
+ frameIds: request.frameIds,
923
+ };
924
+ const rawResult =
925
+ captureRunner === runCaptureScript
926
+ ? await captureRunner({
927
+ ...runnerInput,
928
+ timeoutMs,
929
+ maxPngBytes,
930
+ maxTotalPngBytes: maxBaselineBytes,
931
+ })
932
+ : await withTimeout(
933
+ captureRunner,
934
+ runnerInput,
935
+ timeoutMs,
936
+ );
937
+ const result = normalizeCaptureResult(request.frameIds, rawResult, {
938
+ maxPngBytes,
939
+ maxPngPixels,
940
+ });
941
+ if (request.phase === 'baseline') {
942
+ if (!result.complete) {
943
+ throw new QaCaptureError(
944
+ 422,
945
+ 'baseline_capture_failed',
946
+ 'Not all baseline screens could be captured.',
947
+ result.failures,
948
+ );
949
+ }
950
+ const baselineId = createBaselineId();
951
+ if (typeof baselineId !== 'string' || !BASELINE_ID_RE.test(baselineId)) {
952
+ throw new Error('createBaselineId returned an invalid ID');
953
+ }
954
+ const reservedArtifactIds = new Set();
955
+ const frames = request.frameIds.map((id) => {
956
+ const frame = result.frames.get(id);
957
+ return {
958
+ ...frame,
959
+ ...(frame.screenshotBytes
960
+ ? { screenshotArtifactId: nextArtifactId(reservedArtifactIds) }
961
+ : {}),
962
+ };
963
+ });
964
+ const byteSize = frames.reduce(
965
+ (total, frame) => total + (frame.screenshotBytes?.length ?? 0),
966
+ 0,
967
+ );
968
+ if (byteSize > maxBaselineBytes) {
969
+ throw new QaCaptureError(
970
+ 422,
971
+ 'baseline_artifacts_too_large',
972
+ `Baseline PNG bytes exceed the ${maxBaselineBytes} byte storage limit.`,
973
+ frames
974
+ .filter((frame) => frame.screenshotBytes)
975
+ .map((frame) => ({
976
+ id: frame.id,
977
+ error: 'Baseline PNG bytes exceed the storage limit.',
978
+ })),
979
+ );
980
+ }
981
+ baselines.set(baselineId, {
982
+ basePath: request.basePath,
983
+ frameIds: [...request.frameIds],
984
+ frames: new Map(frames.map((frame) => [frame.id, frame])),
985
+ byteSize,
986
+ });
987
+ return {
988
+ ok: true,
989
+ baselineId,
990
+ frames: frames.map(publicFrame),
991
+ };
992
+ }
993
+
994
+ const diffFailures = [];
995
+ const reviewArtifacts = [];
996
+ const reservedArtifactIds = new Set();
997
+ const reviewGroups = [];
998
+ const frames = request.frameIds.map((id) => {
999
+ const before = baseline.frames.get(id);
1000
+ const after = result.frames.get(id) ?? null;
1001
+ const frame = {
1002
+ id,
1003
+ before: publicHashes(
1004
+ before,
1005
+ before.screenshotArtifactId
1006
+ ? artifactUrl(before.screenshotArtifactId)
1007
+ : null,
1008
+ ),
1009
+ after: after
1010
+ ? publicHashes(after)
1011
+ : null,
1012
+ domChanged: after ? before.domStructureHash !== after.domStructureHash : null,
1013
+ screenshotChanged: after ? before.screenshotHash !== after.screenshotHash : null,
1014
+ };
1015
+ if (!after?.screenshotBytes || !before.screenshotBytes) return frame;
1016
+
1017
+ try {
1018
+ const comparison = comparePngScreenshots(
1019
+ before.screenshotBytes,
1020
+ after.screenshotBytes,
1021
+ {
1022
+ maxPngBytes,
1023
+ maxPngPixels,
1024
+ threshold: pixelThreshold,
1025
+ },
1026
+ );
1027
+ const afterArtifactId = nextArtifactId(reservedArtifactIds);
1028
+ const heatmapArtifactId = nextArtifactId(reservedArtifactIds);
1029
+ const group = {
1030
+ id,
1031
+ frame,
1032
+ afterArtifactId,
1033
+ heatmapArtifactId,
1034
+ artifacts: [
1035
+ { id: afterArtifactId, bytes: after.screenshotBytes },
1036
+ { id: heatmapArtifactId, bytes: comparison.heatmapBytes },
1037
+ ],
1038
+ };
1039
+ reviewGroups.push(group);
1040
+ reviewArtifacts.push(...group.artifacts);
1041
+ frame.pixelDiff = {
1042
+ before: comparison.before,
1043
+ after: comparison.after,
1044
+ dimensionsMatch: comparison.dimensionsMatch,
1045
+ changedPixels: comparison.changedPixels,
1046
+ totalPixels: comparison.totalPixels,
1047
+ diffRatio: comparison.diffRatio,
1048
+ threshold: comparison.threshold,
1049
+ heatmapUrl: artifactUrl(heatmapArtifactId),
1050
+ };
1051
+ } catch (error) {
1052
+ diffFailures.push({
1053
+ id,
1054
+ error: `Pixel diff failed: ${String(error instanceof Error ? error.message : error).slice(0, 900)}`,
1055
+ });
1056
+ }
1057
+ return frame;
1058
+ });
1059
+ if (reviewArtifacts.length && artifacts.setBatch(reviewArtifacts)) {
1060
+ for (const group of reviewGroups) {
1061
+ group.frame.after.screenshotUrl = artifactUrl(group.afterArtifactId);
1062
+ }
1063
+ } else if (reviewArtifacts.length) {
1064
+ for (const group of reviewGroups) {
1065
+ group.frame.pixelDiff.heatmapUrl = null;
1066
+ diffFailures.push({
1067
+ id: group.id,
1068
+ error: 'Review PNGs exceed the artifact count or byte limit.',
1069
+ });
1070
+ }
1071
+ }
1072
+ return {
1073
+ ok: true,
1074
+ baselineId: request.baselineId,
1075
+ beforeBasePath: baseline.basePath,
1076
+ afterBasePath: request.basePath,
1077
+ frames,
1078
+ complete: result.complete && diffFailures.length === 0,
1079
+ failures: [...result.failures, ...diffFailures],
1080
+ };
1081
+ } finally {
1082
+ active = false;
1083
+ }
1084
+ };
1085
+
1086
+ return {
1087
+ capture,
1088
+ getArtifact(id) {
1089
+ if (typeof id !== 'string' || !ARTIFACT_ID_RE.test(id)) return null;
1090
+ const bytes = baselines.getArtifact(id) ?? artifacts.get(id);
1091
+ return bytes ? { bytes, contentType: 'image/png' } : null;
1092
+ },
1093
+ get baselineCount() {
1094
+ return baselines.size;
1095
+ },
1096
+ get artifactCount() {
1097
+ return artifacts.size;
1098
+ },
1099
+ get busy() {
1100
+ return active;
1101
+ },
1102
+ };
1103
+ }
1104
+
1105
+ async function readJson(req) {
1106
+ const chunks = [];
1107
+ let size = 0;
1108
+ let tooLarge = false;
1109
+ for await (const chunk of req) {
1110
+ size += chunk.length;
1111
+ if (size > MAX_BODY_BYTES) {
1112
+ tooLarge = true;
1113
+ continue;
1114
+ }
1115
+ chunks.push(chunk);
1116
+ }
1117
+ if (tooLarge) {
1118
+ throw new QaCaptureError(413, 'request_too_large', 'The QA capture request is too large.');
1119
+ }
1120
+ try {
1121
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
1122
+ } catch {
1123
+ throw new QaCaptureError(400, 'invalid_json', 'A valid JSON request is required.');
1124
+ }
1125
+ }
1126
+
1127
+ function sendJson(res, statusCode, body) {
1128
+ res.statusCode = statusCode;
1129
+ res.setHeader('content-type', 'application/json; charset=utf-8');
1130
+ res.setHeader('cache-control', 'no-store');
1131
+ res.end(JSON.stringify(body));
1132
+ }
1133
+
1134
+ function sendPng(res, artifact) {
1135
+ res.statusCode = 200;
1136
+ res.setHeader('content-type', artifact.contentType);
1137
+ res.setHeader('content-length', String(artifact.bytes.length));
1138
+ res.setHeader('cache-control', 'no-store');
1139
+ res.setHeader('x-content-type-options', 'nosniff');
1140
+ res.end(artifact.bytes);
1141
+ }
1142
+
1143
+ export function pygmalionQaCapturePlugin(options = {}) {
1144
+ const service = createQaCaptureService(options);
1145
+ return {
1146
+ name: 'pygmalion-qa-capture',
1147
+ apply: 'serve',
1148
+ configureServer(server) {
1149
+ if (process.env.PYGMALION_PREVIEW_MODE === '1') return;
1150
+ server.middlewares.use(async (req, res, next) => {
1151
+ const rawTarget = req.url ?? '/';
1152
+ const rawPath = rawTarget.split(/[?#]/, 1)[0];
1153
+ if (rawPath.startsWith(PYGMALION_QA_ARTIFACT_PREFIX.replace(/\/$/, ''))) {
1154
+ if (req.method !== 'GET') {
1155
+ res.setHeader('allow', 'GET');
1156
+ sendJson(res, 405, {
1157
+ ok: false,
1158
+ error: 'method_not_allowed',
1159
+ message: 'Only GET requests are supported.',
1160
+ });
1161
+ return;
1162
+ }
1163
+ if (!isLocalRequestHost(req.headers.host)) {
1164
+ sendJson(res, 403, {
1165
+ ok: false,
1166
+ error: 'artifact_host_forbidden',
1167
+ message: 'QA artifacts are available only from a local Vite host.',
1168
+ });
1169
+ return;
1170
+ }
1171
+ const match =
1172
+ rawTarget === rawPath
1173
+ ? /^\/__pygmalion-qa\/artifacts\/([A-Za-z0-9_-]{8,80})\.png$/.exec(rawPath)
1174
+ : null;
1175
+ if (!match) {
1176
+ sendJson(res, 400, {
1177
+ ok: false,
1178
+ error: 'invalid_artifact_path',
1179
+ message: 'The QA artifact path is invalid.',
1180
+ });
1181
+ return;
1182
+ }
1183
+ const artifact = service.getArtifact(match[1]);
1184
+ if (!artifact) {
1185
+ sendJson(res, 404, {
1186
+ ok: false,
1187
+ error: 'artifact_not_found',
1188
+ message: 'The QA artifact does not exist or has expired.',
1189
+ });
1190
+ return;
1191
+ }
1192
+ sendPng(res, artifact);
1193
+ return;
1194
+ }
1195
+ const url = new URL(rawTarget, 'http://localhost');
1196
+ if (url.pathname !== PYGMALION_QA_CAPTURE_ENDPOINT) {
1197
+ next();
1198
+ return;
1199
+ }
1200
+ if (req.method !== 'POST') {
1201
+ res.setHeader('allow', 'POST');
1202
+ sendJson(res, 405, {
1203
+ ok: false,
1204
+ error: 'method_not_allowed',
1205
+ message: 'Only POST requests are supported.',
1206
+ });
1207
+ return;
1208
+ }
1209
+ try {
1210
+ const result = await service.capture(await readJson(req), {
1211
+ allowedHost: req.headers.host,
1212
+ });
1213
+ sendJson(res, 200, result);
1214
+ } catch (error) {
1215
+ const known = error instanceof QaCaptureError;
1216
+ sendJson(res, known ? error.statusCode : 500, {
1217
+ ok: false,
1218
+ error: known ? error.code : 'internal_error',
1219
+ message: error instanceof Error ? error.message : String(error),
1220
+ ...(known && error.failures ? { failures: error.failures } : {}),
1221
+ });
1222
+ }
1223
+ });
1224
+ },
1225
+ };
1226
+ }