@pygmalionjs/pygmalion 0.2.11 → 0.2.13

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,226 @@
1
+ const CAPTURE_STATUSES = new Set([
2
+ 'ready',
3
+ 'rendered-with-qa-failure',
4
+ 'capture-error',
5
+ ]);
6
+ const NON_READY_STATUSES = new Set([
7
+ 'rendered-with-qa-failure',
8
+ 'capture-error',
9
+ ]);
10
+ const EVIDENCE_KEYS = Object.freeze(['snapshot', 'screenshot', 'domTree']);
11
+
12
+ export const STORYBOARD_CAPTURE_SCHEDULER_LIMITS = Object.freeze({
13
+ defaultConcurrency: 8,
14
+ maximumConcurrency: 16,
15
+ });
16
+
17
+ function assertCaptureResult(result, index, phase) {
18
+ if (
19
+ !result ||
20
+ typeof result !== 'object' ||
21
+ !CAPTURE_STATUSES.has(result.status)
22
+ ) {
23
+ throw new TypeError(
24
+ `Storyboard capture ${index} returned an invalid status during ${phase}.`,
25
+ );
26
+ }
27
+ return result;
28
+ }
29
+
30
+ function captureContext({
31
+ phase,
32
+ attempt,
33
+ index,
34
+ total,
35
+ previousResult,
36
+ signal,
37
+ }) {
38
+ return {
39
+ phase,
40
+ attempt,
41
+ index,
42
+ total,
43
+ isolated: phase !== 'primary',
44
+ requiresFreshContext: true,
45
+ ...(previousResult == null ? {} : { previousResult }),
46
+ ...(signal == null ? {} : { signal }),
47
+ };
48
+ }
49
+
50
+ function throwIfAborted(signal) {
51
+ signal?.throwIfAborted?.();
52
+ if (signal?.aborted) {
53
+ throw signal.reason instanceof Error
54
+ ? signal.reason
55
+ : new Error('Storyboard capture scheduling was aborted.');
56
+ }
57
+ }
58
+
59
+ function mergeRetriedEvidence(previousResult, retryResult) {
60
+ if (!NON_READY_STATUSES.has(retryResult.status)) return retryResult;
61
+ let merged = retryResult;
62
+ for (const key of EVIDENCE_KEYS) {
63
+ if (
64
+ retryResult[key] == null &&
65
+ previousResult[key] != null
66
+ ) {
67
+ if (merged === retryResult) merged = { ...retryResult };
68
+ merged[key] = previousResult[key];
69
+ }
70
+ }
71
+ if (
72
+ (!Array.isArray(retryResult.diagnostics) ||
73
+ retryResult.diagnostics.length === 0) &&
74
+ Array.isArray(previousResult.diagnostics) &&
75
+ previousResult.diagnostics.length > 0
76
+ ) {
77
+ if (merged === retryResult) merged = { ...retryResult };
78
+ merged.diagnostics = previousResult.diagnostics;
79
+ }
80
+ return merged;
81
+ }
82
+
83
+ function resolveConcurrency(value) {
84
+ const concurrency =
85
+ value ?? STORYBOARD_CAPTURE_SCHEDULER_LIMITS.defaultConcurrency;
86
+ if (
87
+ !Number.isInteger(concurrency) ||
88
+ concurrency < 1 ||
89
+ concurrency > STORYBOARD_CAPTURE_SCHEDULER_LIMITS.maximumConcurrency
90
+ ) {
91
+ throw new RangeError(
92
+ `Storyboard capture concurrency must be an integer from 1 to ${STORYBOARD_CAPTURE_SCHEDULER_LIMITS.maximumConcurrency}.`,
93
+ );
94
+ }
95
+ return concurrency;
96
+ }
97
+
98
+ function resolveRetryAttempts(value) {
99
+ const retryAttempts = value ?? 1;
100
+ if (
101
+ !Number.isInteger(retryAttempts) ||
102
+ retryAttempts < 0 ||
103
+ retryAttempts > 3
104
+ ) {
105
+ throw new RangeError(
106
+ 'Storyboard isolated retry attempts must be an integer from 0 to 3.',
107
+ );
108
+ }
109
+ return retryAttempts;
110
+ }
111
+
112
+ /**
113
+ * Schedules capture work without depending on a browser implementation.
114
+ *
115
+ * The first case completes globally before the bounded parallel pass begins.
116
+ * Capture adapters must create and close one fresh execution context for every
117
+ * invocation. Non-ready results receive bounded input-ordered, serial retry
118
+ * passes after the primary pass. A retry that remains non-ready inherits any
119
+ * stable evidence that an earlier attempt captured but the retry could not
120
+ * reproduce.
121
+ */
122
+ export async function scheduleStoryboardCaptures(
123
+ items,
124
+ capture,
125
+ options = {},
126
+ ) {
127
+ if (!Array.isArray(items)) {
128
+ throw new TypeError('Storyboard capture scheduling requires an item array.');
129
+ }
130
+ if (typeof capture !== 'function') {
131
+ throw new TypeError(
132
+ 'Storyboard capture scheduling requires a capture function.',
133
+ );
134
+ }
135
+ const concurrency = resolveConcurrency(options.concurrency);
136
+ const isolatedRetryAttempts = resolveRetryAttempts(
137
+ options.isolatedRetryAttempts,
138
+ );
139
+ const signal = options.signal;
140
+ if (items.length === 0) return [];
141
+ throwIfAborted(signal);
142
+
143
+ const results = new Array(items.length);
144
+ results[0] = assertCaptureResult(
145
+ await capture(
146
+ items[0],
147
+ captureContext({
148
+ phase: 'warm-up',
149
+ attempt: 1,
150
+ index: 0,
151
+ total: items.length,
152
+ signal,
153
+ }),
154
+ ),
155
+ 0,
156
+ 'warm-up',
157
+ );
158
+
159
+ let nextIndex = 1;
160
+ async function primaryWorker() {
161
+ while (nextIndex < items.length) {
162
+ throwIfAborted(signal);
163
+ const index = nextIndex;
164
+ nextIndex += 1;
165
+ results[index] = assertCaptureResult(
166
+ await capture(
167
+ items[index],
168
+ captureContext({
169
+ phase: 'primary',
170
+ attempt: 1,
171
+ index,
172
+ total: items.length,
173
+ signal,
174
+ }),
175
+ ),
176
+ index,
177
+ 'primary',
178
+ );
179
+ }
180
+ }
181
+ await Promise.all(
182
+ Array.from(
183
+ {
184
+ length: Math.min(concurrency, Math.max(0, items.length - 1)),
185
+ },
186
+ () => primaryWorker(),
187
+ ),
188
+ );
189
+
190
+ for (
191
+ let retryAttempt = 1;
192
+ retryAttempt <= isolatedRetryAttempts;
193
+ retryAttempt += 1
194
+ ) {
195
+ const retryIndexes = results.flatMap((result, index) =>
196
+ NON_READY_STATUSES.has(result.status) ? [index] : [],
197
+ );
198
+ if (retryIndexes.length === 0) break;
199
+ options.onRetryPass?.({
200
+ attempt: retryAttempt,
201
+ totalAttempts: isolatedRetryAttempts,
202
+ indexes: [...retryIndexes],
203
+ });
204
+ for (const index of retryIndexes) {
205
+ throwIfAborted(signal);
206
+ const previousResult = results[index];
207
+ const retryResult = assertCaptureResult(
208
+ await capture(
209
+ items[index],
210
+ captureContext({
211
+ phase: 'isolated-retry',
212
+ attempt: retryAttempt + 1,
213
+ index,
214
+ total: items.length,
215
+ previousResult,
216
+ signal,
217
+ }),
218
+ ),
219
+ index,
220
+ 'isolated-retry',
221
+ );
222
+ results[index] = mergeRetriedEvidence(previousResult, retryResult);
223
+ }
224
+ }
225
+ return results;
226
+ }
@@ -0,0 +1,44 @@
1
+ export {
2
+ PYGMALION_STORYBOARD_CANONICAL_CONTROL,
3
+ canonicalizeStoryboardExecutions,
4
+ executeStoryboardCandidates,
5
+ fingerprintStoryboardSnapshot,
6
+ normalizeStoryboardSnapshot,
7
+ recommendedStoryboardExecutionConcurrency,
8
+ } from './storyboard-canonical.mjs';
9
+
10
+ export {
11
+ STORYBOARD_CAPTURE_STATUSES,
12
+ StoryboardCaptureStageError,
13
+ assertFinalStoryboardState,
14
+ captureStoryboardCase,
15
+ collectStoryboardDomTree,
16
+ createStoryboardArtifactRecord,
17
+ findUnassertedStoryboardModal,
18
+ hashStoryboardCapture,
19
+ normalizeStoryboardDomStructure,
20
+ resolveStoryboardCaptureRoute,
21
+ resolveStoryboardCaptureUrl,
22
+ resolveStoryboardFixedTime,
23
+ runStoryboardAssertion,
24
+ runStoryboardInteraction,
25
+ serializeStoryboardPreviewDocument,
26
+ storyboardDocumentStabilitySignature,
27
+ storyboardCaptureCandidateTexts,
28
+ waitForStableStoryboardDocument,
29
+ } from './storyboard-capture-runtime.mjs';
30
+
31
+ export {
32
+ STORYBOARD_CAPTURE_SCHEDULER_LIMITS,
33
+ scheduleStoryboardCaptures,
34
+ } from './storyboard-capture-scheduler.mjs';
35
+
36
+ export {
37
+ ROUTE_PREVIEW_ARTIFACT_V3_LIMITS,
38
+ createRoutePreviewArtifactV3,
39
+ reconstructRoutePreviewArtifactScreenshotV3,
40
+ reconstructRoutePreviewArtifactSnapshotV3,
41
+ reconstructRoutePreviewArtifactSnapshotsV3,
42
+ validateRoutePreviewArtifactBundle,
43
+ validateRoutePreviewArtifactV3,
44
+ } from './route-preview-artifact-v3.mjs';
package/node/vite.mjs CHANGED
@@ -51,6 +51,33 @@ import {
51
51
  ROUTE_PREVIEW_ARTIFACT_V2_LIMITS,
52
52
  validateRoutePreviewArtifactV2,
53
53
  } from './route-preview-artifact-v2.mjs';
54
+ import {
55
+ ROUTE_PREVIEW_ARTIFACT_V3_LIMITS,
56
+ createRoutePreviewArtifactV3,
57
+ reconstructRoutePreviewArtifactScreenshotV3,
58
+ reconstructRoutePreviewArtifactSnapshotV3,
59
+ reconstructRoutePreviewArtifactSnapshotsV3,
60
+ validateRoutePreviewArtifactBundle,
61
+ validateRoutePreviewArtifactV3,
62
+ } from './route-preview-artifact-v3.mjs';
63
+ import {
64
+ DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
65
+ PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
66
+ pygmalionPreviewArtifactPlugin,
67
+ } from './preview-artifact-plugin.mjs';
68
+ import {
69
+ DEFAULT_QA_CAPTURE_MAX_FRAMES,
70
+ PYGMALION_QA_ARTIFACT_PREFIX,
71
+ PYGMALION_QA_CAPTURE_ENDPOINT,
72
+ QaCaptureError,
73
+ comparePngScreenshots,
74
+ createQaCaptureService,
75
+ normalizeLocalCaptureBasePath,
76
+ pygmalionQaCapturePlugin,
77
+ runCaptureScript,
78
+ validateQaCaptureRequest,
79
+ } from './qa-capture-plugin.mjs';
80
+ import { resolveGitSourceState } from './source-revision.mjs';
54
81
 
55
82
  function requiredPath(value, name, base) {
56
83
  if (typeof value !== 'string' || value.trim() === '') {
@@ -73,8 +100,14 @@ function optionalNonEmptyString(value, name) {
73
100
 
74
101
  function normalizeEndpoint(value, fallback, name) {
75
102
  const endpoint = value ?? fallback;
76
- if (typeof endpoint !== 'string' || !endpoint.startsWith('/') || endpoint.endsWith('/')) {
77
- throw new Error(`Pygmalion ${name} must start with "/" and must not end with "/"`);
103
+ if (
104
+ typeof endpoint !== 'string' ||
105
+ !endpoint.startsWith('/') ||
106
+ endpoint.endsWith('/')
107
+ ) {
108
+ throw new Error(
109
+ `Pygmalion ${name} must start with "/" and must not end with "/"`,
110
+ );
78
111
  }
79
112
  return endpoint;
80
113
  }
@@ -90,27 +123,45 @@ export function resolvePygmalionProject(config) {
90
123
  definePygmalionProject(config);
91
124
 
92
125
  const configRoot = path.resolve(config.configRoot ?? process.cwd());
93
- const projectRoot = requiredPath(config.projectRoot, 'projectRoot', configRoot);
94
- const appRoot = requiredPath(config.appRoot ?? projectRoot, 'appRoot', configRoot);
126
+ const projectRoot = requiredPath(
127
+ config.projectRoot,
128
+ 'projectRoot',
129
+ configRoot,
130
+ );
131
+ const appRoot = requiredPath(
132
+ config.appRoot ?? projectRoot,
133
+ 'appRoot',
134
+ configRoot,
135
+ );
95
136
  const editorRoot = path.resolve(configRoot, config.editorRoot ?? appRoot);
96
- const appDirectory = (config.appDirectory ?? path.relative(projectRoot, appRoot) ?? '.')
97
- .split(path.sep)
98
- .join('/') || '.';
137
+ const appDirectory =
138
+ (config.appDirectory ?? path.relative(projectRoot, appRoot) ?? '.')
139
+ .split(path.sep)
140
+ .join('/') || '.';
99
141
  const source = optionalObject(config.source);
100
142
  const branch = source.branch ?? 'dev';
101
143
  const remote = source.remote ?? 'origin';
102
144
  const ref = optionalNonEmptyString(source.ref, 'source.ref');
103
- const mirror = config.mirror === false ? false : optionalObject(config.mirror);
104
- const sessions = config.sessions === false ? false : optionalObject(config.sessions);
105
- const inspect = config.inspect === false ? false : optionalObject(config.inspect);
145
+ const mirror =
146
+ config.mirror === false ? false : optionalObject(config.mirror);
147
+ const sessions =
148
+ config.sessions === false ? false : optionalObject(config.sessions);
149
+ const inspect =
150
+ config.inspect === false ? false : optionalObject(config.inspect);
106
151
  const preview = optionalObject(config.preview);
152
+ const qa = config.qa === false ? false : optionalObject(config.qa);
107
153
  const dependencies = optionalObject(config.dependencies);
108
- const inventory = config.inventory ? optionalObject(config.inventory) : undefined;
154
+ const inventory = config.inventory
155
+ ? optionalObject(config.inventory)
156
+ : undefined;
109
157
  const mirrorRoot = path.resolve(
110
158
  configRoot,
111
159
  config.mirrorRoot ??
112
160
  mirror?.root ??
113
- path.join(path.dirname(projectRoot), `${path.basename(projectRoot)}-${branch}-view`),
161
+ path.join(
162
+ path.dirname(projectRoot),
163
+ `${path.basename(projectRoot)}-${branch}-view`,
164
+ ),
114
165
  );
115
166
  const mirrorAppRoot = path.resolve(
116
167
  configRoot,
@@ -119,7 +170,9 @@ export function resolvePygmalionProject(config) {
119
170
  (appDirectory === '.' ? mirrorRoot : path.join(mirrorRoot, appDirectory)),
120
171
  );
121
172
  const sourceDirectory =
122
- (config.sourceDirectory ?? 'src').replace(/^\.?\//, '').replace(/\/$/, '') || '.';
173
+ (config.sourceDirectory ?? 'src')
174
+ .replace(/^\.?\//, '')
175
+ .replace(/\/$/, '') || '.';
123
176
 
124
177
  return {
125
178
  configRoot,
@@ -137,6 +190,7 @@ export function resolvePygmalionProject(config) {
137
190
  sessions,
138
191
  inspect,
139
192
  preview,
193
+ qa,
140
194
  dependencies,
141
195
  inventory,
142
196
  };
@@ -155,6 +209,32 @@ export function createPygmalionVitePlugins(config) {
155
209
  }),
156
210
  ];
157
211
 
212
+ if (project.preview.artifactFile || project.preview.generateArtifact) {
213
+ plugins.push(
214
+ pygmalionPreviewArtifactPlugin({
215
+ root: project.appRoot,
216
+ artifactFile: project.preview.artifactFile,
217
+ endpoint: project.preview.artifactEndpoint,
218
+ generateArtifact: project.preview.generateArtifact,
219
+ }),
220
+ );
221
+ }
222
+
223
+ if (
224
+ project.qa !== false &&
225
+ (project.qa.captureScript || project.qa.captureRunner)
226
+ ) {
227
+ plugins.push(
228
+ pygmalionQaCapturePlugin({
229
+ ...project.qa,
230
+ root: project.appRoot,
231
+ scriptPath: project.qa.captureScript
232
+ ? path.resolve(project.appRoot, project.qa.captureScript)
233
+ : undefined,
234
+ }),
235
+ );
236
+ }
237
+
158
238
  if (project.inspect !== false) {
159
239
  plugins.push(
160
240
  pygmalionInspectPlugin({
@@ -218,7 +298,8 @@ export function createPygmalionVitePlugins(config) {
218
298
  previewConfig: project.preview.configFile,
219
299
  viteConfig: project.preview.viteConfig,
220
300
  viteBin: project.preview.viteBin,
221
- previewPort: project.sessions.previewPort ?? project.preview.sessionPort,
301
+ previewPort:
302
+ project.sessions.previewPort ?? project.preview.sessionPort,
222
303
  worktreesRoot: project.sessions.worktreesRoot,
223
304
  prefix: normalizeEndpoint(
224
305
  project.sessions.prefix,
@@ -274,4 +355,25 @@ export {
274
355
  reconstructRoutePreviewArtifactSnapshots,
275
356
  ROUTE_PREVIEW_ARTIFACT_V2_LIMITS,
276
357
  validateRoutePreviewArtifactV2,
358
+ ROUTE_PREVIEW_ARTIFACT_V3_LIMITS,
359
+ createRoutePreviewArtifactV3,
360
+ reconstructRoutePreviewArtifactScreenshotV3,
361
+ reconstructRoutePreviewArtifactSnapshotV3,
362
+ reconstructRoutePreviewArtifactSnapshotsV3,
363
+ validateRoutePreviewArtifactBundle,
364
+ validateRoutePreviewArtifactV3,
365
+ DEFAULT_PYGMALION_PREVIEW_ARTIFACT_FILE,
366
+ PYGMALION_PREVIEW_ARTIFACT_ENDPOINT,
367
+ pygmalionPreviewArtifactPlugin,
368
+ DEFAULT_QA_CAPTURE_MAX_FRAMES,
369
+ PYGMALION_QA_ARTIFACT_PREFIX,
370
+ PYGMALION_QA_CAPTURE_ENDPOINT,
371
+ QaCaptureError,
372
+ comparePngScreenshots,
373
+ createQaCaptureService,
374
+ normalizeLocalCaptureBasePath,
375
+ pygmalionQaCapturePlugin,
376
+ runCaptureScript,
377
+ validateQaCaptureRequest,
378
+ resolveGitSourceState,
277
379
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pygmalionjs/pygmalion",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Code-backed DOM design sandbox and visual QA editor",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -19,6 +19,10 @@
19
19
  "types": "./vite.d.ts",
20
20
  "default": "./node/vite.mjs"
21
21
  },
22
+ "./storyboard": {
23
+ "types": "./storyboard.d.ts",
24
+ "default": "./node/storyboard.mjs"
25
+ },
22
26
  "./dev-view": "./node/dev-view.vite.mjs",
23
27
  "./testing": {
24
28
  "types": "./testing.d.ts",
@@ -32,13 +36,22 @@
32
36
  "node/dev-mirror.mjs",
33
37
  "node/dev-view.vite.mjs",
34
38
  "node/inspect-plugin.mjs",
39
+ "node/preview-artifact-plugin.mjs",
40
+ "node/qa-capture-plugin.mjs",
35
41
  "node/route-preview-artifact-v2.mjs",
42
+ "node/route-preview-artifact-v3.mjs",
43
+ "node/source-revision.mjs",
36
44
  "node/source-diff.mjs",
37
45
  "node/source-graph.mjs",
38
46
  "node/source-operations.mjs",
39
47
  "node/storyboard-canonical.mjs",
48
+ "node/storyboard-capture-scheduler.mjs",
49
+ "node/storyboard-capture-runtime.mjs",
40
50
  "node/storyboard-environment.mjs",
51
+ "node/storyboard.mjs",
41
52
  "node/vite.mjs",
53
+ "qa.d.ts",
54
+ "storyboard.d.ts",
42
55
  "types.d.ts",
43
56
  "testing.d.ts",
44
57
  "vite.d.ts"
@@ -67,6 +80,8 @@
67
80
  "magic-string": "^0.30.17",
68
81
  "mobx": "^6.13.0",
69
82
  "mobx-react-lite": "^4.0.7",
83
+ "pixelmatch": "^7.2.0",
84
+ "pngjs": "^7.0.0",
70
85
  "typescript": "^5.8.0"
71
86
  },
72
87
  "devDependencies": {
package/qa.d.ts ADDED
@@ -0,0 +1,201 @@
1
+ import type { Plugin } from 'vite';
2
+
3
+ export const PYGMALION_QA_CAPTURE_ENDPOINT: '/__pygmalion-qa/capture';
4
+ export const PYGMALION_QA_ARTIFACT_PREFIX: '/__pygmalion-qa/artifacts/';
5
+ export const DEFAULT_QA_CAPTURE_MAX_FRAMES: number;
6
+
7
+ export interface QaCaptureHashes {
8
+ domStructureHash: string;
9
+ screenshotHash: string;
10
+ screenshotUrl?: string;
11
+ }
12
+
13
+ export interface QaCaptureFrame extends QaCaptureHashes {
14
+ id: string;
15
+ /** Runner-only PNG bytes; never serialized in a capture response. */
16
+ screenshotBytes?: Uint8Array;
17
+ }
18
+
19
+ export interface QaCaptureFailure {
20
+ id: string;
21
+ error: string;
22
+ }
23
+
24
+ export interface QaCaptureRunnerInput {
25
+ root: string;
26
+ scriptPath: string;
27
+ basePath: string;
28
+ frameIds: string[];
29
+ timeoutMs: number;
30
+ maxPngBytes?: number;
31
+ maxTotalPngBytes?: number;
32
+ signal?: AbortSignal;
33
+ }
34
+
35
+ export interface QaCaptureRunnerResult {
36
+ screens: QaCaptureFrame[];
37
+ failures: QaCaptureFailure[];
38
+ }
39
+
40
+ export type QaCaptureRunner = (
41
+ input: QaCaptureRunnerInput,
42
+ ) => Promise<QaCaptureRunnerResult>;
43
+
44
+ export interface QaCaptureBaselineRequest {
45
+ phase: 'baseline';
46
+ basePath: string;
47
+ frameIds: string[];
48
+ }
49
+
50
+ export interface QaCaptureChangedRequest {
51
+ phase: 'changed';
52
+ basePath: string;
53
+ frameIds: string[];
54
+ baselineId: string;
55
+ }
56
+
57
+ export type QaCaptureRequest = QaCaptureBaselineRequest | QaCaptureChangedRequest;
58
+
59
+ export interface QaCaptureBaselineResponse {
60
+ ok: true;
61
+ baselineId: string;
62
+ frames: QaCaptureFrame[];
63
+ }
64
+
65
+ export interface QaCaptureChangedFrame {
66
+ id: string;
67
+ before: QaCaptureHashes;
68
+ after: QaCaptureHashes | null;
69
+ domChanged: boolean | null;
70
+ screenshotChanged: boolean | null;
71
+ pixelDiff?: QaPixelDiff;
72
+ }
73
+
74
+ export interface QaPixelDimensions {
75
+ width: number;
76
+ height: number;
77
+ }
78
+
79
+ export interface QaPixelDiff {
80
+ before: QaPixelDimensions;
81
+ after: QaPixelDimensions;
82
+ dimensionsMatch: boolean;
83
+ changedPixels: number;
84
+ totalPixels: number;
85
+ diffRatio: number;
86
+ threshold: number;
87
+ heatmapUrl: string | null;
88
+ }
89
+
90
+ export interface QaCaptureChangedResponse {
91
+ ok: true;
92
+ baselineId: string;
93
+ beforeBasePath: string;
94
+ afterBasePath: string;
95
+ frames: QaCaptureChangedFrame[];
96
+ complete: boolean;
97
+ failures: QaCaptureFailure[];
98
+ }
99
+
100
+ export interface QaCaptureErrorResponse {
101
+ ok: false;
102
+ error: string;
103
+ message: string;
104
+ failures?: QaCaptureFailure[];
105
+ }
106
+
107
+ export type QaCaptureResponse =
108
+ | QaCaptureBaselineResponse
109
+ | QaCaptureChangedResponse
110
+ | QaCaptureErrorResponse;
111
+
112
+ export class QaCaptureError extends Error {
113
+ statusCode: number;
114
+ code: string;
115
+ failures?: QaCaptureFailure[];
116
+ constructor(
117
+ statusCode: number,
118
+ code: string,
119
+ message: string,
120
+ failures?: QaCaptureFailure[],
121
+ );
122
+ }
123
+
124
+ export interface QaCaptureServiceOptions {
125
+ root?: string;
126
+ scriptPath?: string;
127
+ captureRunner?: QaCaptureRunner;
128
+ timeoutMs?: number;
129
+ maxFrameIds?: number;
130
+ maxBaselines?: number;
131
+ baselineTtlMs?: number;
132
+ maxBaselineBytes?: number;
133
+ maxArtifacts?: number;
134
+ maxArtifactBytes?: number;
135
+ artifactTtlMs?: number;
136
+ maxPngBytes?: number;
137
+ maxPngPixels?: number;
138
+ pixelThreshold?: number;
139
+ now?: () => number;
140
+ createBaselineId?: () => string;
141
+ createArtifactId?: () => string;
142
+ }
143
+
144
+ export interface QaCaptureService {
145
+ capture(
146
+ request: QaCaptureRequest | unknown,
147
+ context?: { allowedHost?: string | string[] },
148
+ ): Promise<QaCaptureBaselineResponse | QaCaptureChangedResponse>;
149
+ getArtifact(
150
+ id: string,
151
+ ): { bytes: Uint8Array; contentType: 'image/png' } | null;
152
+ readonly baselineCount: number;
153
+ readonly artifactCount: number;
154
+ readonly busy: boolean;
155
+ }
156
+
157
+ export interface QaPngComparison {
158
+ before: QaPixelDimensions;
159
+ after: QaPixelDimensions;
160
+ dimensionsMatch: boolean;
161
+ changedPixels: number;
162
+ totalPixels: number;
163
+ diffRatio: number;
164
+ threshold: number;
165
+ heatmapBytes: Uint8Array;
166
+ }
167
+
168
+ export function normalizeLocalCaptureBasePath(
169
+ value: unknown,
170
+ allowedHost?: string | string[],
171
+ ): string;
172
+
173
+ export function validateQaCaptureRequest(
174
+ value: unknown,
175
+ options?: {
176
+ allowedHost?: string | string[];
177
+ maxFrameIds?: number;
178
+ },
179
+ ): QaCaptureRequest;
180
+
181
+ export function runCaptureScript(
182
+ input: QaCaptureRunnerInput,
183
+ ): Promise<QaCaptureRunnerResult>;
184
+
185
+ export function comparePngScreenshots(
186
+ beforeBytes: Uint8Array,
187
+ afterBytes: Uint8Array,
188
+ options?: {
189
+ maxPngBytes?: number;
190
+ maxPngPixels?: number;
191
+ threshold?: number;
192
+ },
193
+ ): QaPngComparison;
194
+
195
+ export function createQaCaptureService(
196
+ options?: QaCaptureServiceOptions,
197
+ ): QaCaptureService;
198
+
199
+ export function pygmalionQaCapturePlugin(
200
+ options?: QaCaptureServiceOptions,
201
+ ): Plugin;