@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.
- package/README.ko.md +36 -10
- package/README.md +36 -10
- package/dist-lib/{App-CyLtIqGh.js → App-BpS2IDVb.js} +7992 -6049
- package/dist-lib/pygmalion.js +2044 -361
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/dev-mirror.mjs +68 -1
- package/node/dev-view.vite.mjs +7 -0
- package/node/inspect-plugin.mjs +8 -1
- package/node/preview-artifact-plugin.mjs +296 -0
- package/node/qa-capture-plugin.mjs +1226 -0
- package/node/route-preview-artifact-v3.mjs +584 -0
- package/node/source-revision.mjs +83 -0
- package/node/storyboard-capture-runtime.mjs +1165 -0
- package/node/storyboard-capture-scheduler.mjs +226 -0
- package/node/storyboard.mjs +44 -0
- package/node/vite.mjs +116 -14
- package/package.json +16 -1
- package/qa.d.ts +201 -0
- package/storyboard.d.ts +298 -0
- package/types.d.ts +724 -40
- package/vite.d.ts +102 -20
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
createRoutePreviewArtifactV2,
|
|
4
|
+
reconstructRoutePreviewArtifactSnapshot,
|
|
5
|
+
reconstructRoutePreviewArtifactSnapshots,
|
|
6
|
+
validateRoutePreviewArtifactV2,
|
|
7
|
+
} from './route-preview-artifact-v2.mjs';
|
|
8
|
+
|
|
9
|
+
const HASH_PATTERN = /^sha256-[a-f0-9]{64}$/;
|
|
10
|
+
const TOKEN_PATTERN = /^[A-Za-z][A-Za-z0-9]*(?:[-_.:][A-Za-z0-9]+)*$/;
|
|
11
|
+
const BASE64_PATTERN =
|
|
12
|
+
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
|
|
13
|
+
const FORBIDDEN_RECORD_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
14
|
+
const STATUSES = new Set([
|
|
15
|
+
'ready',
|
|
16
|
+
'rendered-with-qa-failure',
|
|
17
|
+
'capture-error',
|
|
18
|
+
]);
|
|
19
|
+
const QA_STAGES = new Set(['interaction', 'assertion']);
|
|
20
|
+
const MEDIA_TYPES = new Set(['image/png', 'image/webp']);
|
|
21
|
+
|
|
22
|
+
export const ROUTE_PREVIEW_ARTIFACT_V3_LIMITS = Object.freeze({
|
|
23
|
+
bundleBytes: 256 * 1024 * 1024,
|
|
24
|
+
screenshotAssetBytes: 16 * 1024 * 1024,
|
|
25
|
+
screenshotAssetCount: 2_048,
|
|
26
|
+
frameCount: 2_048,
|
|
27
|
+
diagnosticCountPerFrame: 256,
|
|
28
|
+
identifierLength: 512,
|
|
29
|
+
tokenLength: 128,
|
|
30
|
+
selectorLength: 4_096,
|
|
31
|
+
labelLength: 1_024,
|
|
32
|
+
messageLength: 4_096,
|
|
33
|
+
viewportDimension: 32_768,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function byteLength(value) {
|
|
37
|
+
return Buffer.byteLength(value, 'utf8');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function safeJsonByteLength(value) {
|
|
41
|
+
try {
|
|
42
|
+
return byteLength(JSON.stringify(value));
|
|
43
|
+
} catch {
|
|
44
|
+
return Number.POSITIVE_INFINITY;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPlainRecord(value) {
|
|
49
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
50
|
+
const prototype = Object.getPrototypeOf(value);
|
|
51
|
+
return prototype === Object.prototype || prototype === null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function validIdentifier(value) {
|
|
55
|
+
return (
|
|
56
|
+
typeof value === 'string' &&
|
|
57
|
+
value.length > 0 &&
|
|
58
|
+
value.length <= ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.identifierLength &&
|
|
59
|
+
value === value.trim() &&
|
|
60
|
+
!FORBIDDEN_RECORD_KEYS.has(value)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function validOptionalIdentifier(value) {
|
|
65
|
+
return value == null || validIdentifier(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validViewport(value) {
|
|
69
|
+
return (
|
|
70
|
+
isPlainRecord(value) &&
|
|
71
|
+
Number.isInteger(value.width) &&
|
|
72
|
+
value.width > 0 &&
|
|
73
|
+
value.width <= ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.viewportDimension &&
|
|
74
|
+
Number.isInteger(value.height) &&
|
|
75
|
+
value.height > 0 &&
|
|
76
|
+
value.height <= ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.viewportDimension
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function sameViewport(left, right) {
|
|
81
|
+
return (
|
|
82
|
+
validViewport(left) &&
|
|
83
|
+
validViewport(right) &&
|
|
84
|
+
left.width === right.width &&
|
|
85
|
+
left.height === right.height
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function copyViewport(value) {
|
|
90
|
+
if (!validViewport(value)) return value;
|
|
91
|
+
return { width: value.width, height: value.height };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validToken(value) {
|
|
95
|
+
return (
|
|
96
|
+
typeof value === 'string' &&
|
|
97
|
+
value.length > 0 &&
|
|
98
|
+
value.length <= ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.tokenLength &&
|
|
99
|
+
TOKEN_PATTERN.test(value)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validOptionalText(value, maximumLength) {
|
|
104
|
+
return (
|
|
105
|
+
value == null ||
|
|
106
|
+
(typeof value === 'string' &&
|
|
107
|
+
value.length > 0 &&
|
|
108
|
+
value.length <= maximumLength &&
|
|
109
|
+
!/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value))
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function screenshotContentHash(mediaType, width, height, bytes) {
|
|
114
|
+
return `sha256-${createHash('sha256')
|
|
115
|
+
.update('screenshot')
|
|
116
|
+
.update('\0')
|
|
117
|
+
.update(mediaType)
|
|
118
|
+
.update('\0')
|
|
119
|
+
.update(`${width}x${height}`)
|
|
120
|
+
.update('\0')
|
|
121
|
+
.update(bytes)
|
|
122
|
+
.digest('hex')}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sortedRecord(entries) {
|
|
126
|
+
return Object.fromEntries(
|
|
127
|
+
[...entries].sort(([left], [right]) => left.localeCompare(right)),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeScreenshot(rawScreenshot) {
|
|
132
|
+
if (
|
|
133
|
+
!isPlainRecord(rawScreenshot) ||
|
|
134
|
+
!MEDIA_TYPES.has(rawScreenshot.mediaType) ||
|
|
135
|
+
!Number.isInteger(rawScreenshot.width) ||
|
|
136
|
+
!Number.isInteger(rawScreenshot.height)
|
|
137
|
+
) {
|
|
138
|
+
throw new TypeError('Route preview screenshot metadata is invalid.');
|
|
139
|
+
}
|
|
140
|
+
const rawBytes = rawScreenshot.bytes;
|
|
141
|
+
if (!(rawBytes instanceof Uint8Array)) {
|
|
142
|
+
throw new TypeError('Route preview screenshot bytes must be a Uint8Array.');
|
|
143
|
+
}
|
|
144
|
+
const bytes = Buffer.from(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
|
|
145
|
+
if (
|
|
146
|
+
bytes.byteLength === 0 ||
|
|
147
|
+
bytes.byteLength > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.screenshotAssetBytes
|
|
148
|
+
) {
|
|
149
|
+
throw new RangeError('Route preview screenshot exceeds the byte limit.');
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
rawScreenshot.width <= 0 ||
|
|
153
|
+
rawScreenshot.width > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.viewportDimension ||
|
|
154
|
+
rawScreenshot.height <= 0 ||
|
|
155
|
+
rawScreenshot.height > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.viewportDimension
|
|
156
|
+
) {
|
|
157
|
+
throw new RangeError('Route preview screenshot dimensions are invalid.');
|
|
158
|
+
}
|
|
159
|
+
const hash = screenshotContentHash(
|
|
160
|
+
rawScreenshot.mediaType,
|
|
161
|
+
rawScreenshot.width,
|
|
162
|
+
rawScreenshot.height,
|
|
163
|
+
bytes,
|
|
164
|
+
);
|
|
165
|
+
return {
|
|
166
|
+
reference: {
|
|
167
|
+
hash,
|
|
168
|
+
mediaType: rawScreenshot.mediaType,
|
|
169
|
+
width: rawScreenshot.width,
|
|
170
|
+
height: rawScreenshot.height,
|
|
171
|
+
},
|
|
172
|
+
asset: {
|
|
173
|
+
mediaType: rawScreenshot.mediaType,
|
|
174
|
+
width: rawScreenshot.width,
|
|
175
|
+
height: rawScreenshot.height,
|
|
176
|
+
data: bytes.toString('base64'),
|
|
177
|
+
byteLength: bytes.byteLength,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeDiagnostic(rawDiagnostic, viewport) {
|
|
183
|
+
if (!isPlainRecord(rawDiagnostic)) {
|
|
184
|
+
throw new TypeError('Route preview diagnostic must be a record.');
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
stage: rawDiagnostic.stage,
|
|
188
|
+
code: rawDiagnostic.code,
|
|
189
|
+
...(rawDiagnostic.selector == null ? {} : { selector: rawDiagnostic.selector }),
|
|
190
|
+
...(rawDiagnostic.label == null ? {} : { label: rawDiagnostic.label }),
|
|
191
|
+
...(rawDiagnostic.message == null ? {} : { message: rawDiagnostic.message }),
|
|
192
|
+
viewport: copyViewport(rawDiagnostic.viewport ?? viewport),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function projectArtifactV2(bundle) {
|
|
197
|
+
const frames = {};
|
|
198
|
+
if (isPlainRecord(bundle?.frames)) {
|
|
199
|
+
for (const [frameId, frame] of Object.entries(bundle.frames)) {
|
|
200
|
+
if (isPlainRecord(frame) && frame.snapshot !== undefined) {
|
|
201
|
+
frames[frameId] = frame.snapshot;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
version: 2,
|
|
207
|
+
namespace: bundle?.namespace,
|
|
208
|
+
...(bundle?.sourceRevision == null
|
|
209
|
+
? {}
|
|
210
|
+
: { sourceRevision: bundle.sourceRevision }),
|
|
211
|
+
assets: {
|
|
212
|
+
head: bundle?.assets?.head,
|
|
213
|
+
stylesheets: bundle?.assets?.stylesheets,
|
|
214
|
+
},
|
|
215
|
+
frames,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function inspectScreenshotAssets(value, errors) {
|
|
220
|
+
if (!isPlainRecord(value)) {
|
|
221
|
+
errors.push('Screenshot assets must be a record.');
|
|
222
|
+
return new Map();
|
|
223
|
+
}
|
|
224
|
+
const assets = new Map();
|
|
225
|
+
for (const [hash, rawAsset] of Object.entries(value)) {
|
|
226
|
+
if (!HASH_PATTERN.test(hash) || !isPlainRecord(rawAsset)) {
|
|
227
|
+
errors.push(`Screenshot asset "${hash}" is invalid.`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (
|
|
231
|
+
!MEDIA_TYPES.has(rawAsset.mediaType) ||
|
|
232
|
+
!Number.isInteger(rawAsset.width) ||
|
|
233
|
+
rawAsset.width <= 0 ||
|
|
234
|
+
rawAsset.width > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.viewportDimension ||
|
|
235
|
+
!Number.isInteger(rawAsset.height) ||
|
|
236
|
+
rawAsset.height <= 0 ||
|
|
237
|
+
rawAsset.height > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.viewportDimension ||
|
|
238
|
+
!Number.isInteger(rawAsset.byteLength) ||
|
|
239
|
+
rawAsset.byteLength <= 0 ||
|
|
240
|
+
rawAsset.byteLength >
|
|
241
|
+
ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.screenshotAssetBytes ||
|
|
242
|
+
typeof rawAsset.data !== 'string' ||
|
|
243
|
+
!BASE64_PATTERN.test(rawAsset.data)
|
|
244
|
+
) {
|
|
245
|
+
errors.push(`Screenshot asset "${hash}" metadata is invalid.`);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const bytes = Buffer.from(rawAsset.data, 'base64');
|
|
249
|
+
if (
|
|
250
|
+
bytes.byteLength !== rawAsset.byteLength ||
|
|
251
|
+
bytes.toString('base64') !== rawAsset.data
|
|
252
|
+
) {
|
|
253
|
+
errors.push(`Screenshot asset "${hash}" data is invalid.`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const expectedHash = screenshotContentHash(
|
|
257
|
+
rawAsset.mediaType,
|
|
258
|
+
rawAsset.width,
|
|
259
|
+
rawAsset.height,
|
|
260
|
+
bytes,
|
|
261
|
+
);
|
|
262
|
+
if (expectedHash !== hash) {
|
|
263
|
+
errors.push(`Screenshot asset "${hash}" does not match its content hash.`);
|
|
264
|
+
}
|
|
265
|
+
assets.set(hash, rawAsset);
|
|
266
|
+
}
|
|
267
|
+
if (
|
|
268
|
+
assets.size > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.screenshotAssetCount
|
|
269
|
+
) {
|
|
270
|
+
errors.push('Artifact has too many screenshot assets.');
|
|
271
|
+
}
|
|
272
|
+
return assets;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function inspectDiagnostic(rawDiagnostic, frameId, viewport, errors) {
|
|
276
|
+
if (!isPlainRecord(rawDiagnostic)) {
|
|
277
|
+
errors.push(`Frame "${frameId}" contains an invalid diagnostic.`);
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
let valid = true;
|
|
281
|
+
if (!validToken(rawDiagnostic.stage)) {
|
|
282
|
+
errors.push(`Frame "${frameId}" diagnostic stage is invalid.`);
|
|
283
|
+
valid = false;
|
|
284
|
+
}
|
|
285
|
+
if (!validToken(rawDiagnostic.code)) {
|
|
286
|
+
errors.push(`Frame "${frameId}" diagnostic code is invalid.`);
|
|
287
|
+
valid = false;
|
|
288
|
+
}
|
|
289
|
+
if (
|
|
290
|
+
!validOptionalText(
|
|
291
|
+
rawDiagnostic.selector,
|
|
292
|
+
ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.selectorLength,
|
|
293
|
+
)
|
|
294
|
+
) {
|
|
295
|
+
errors.push(`Frame "${frameId}" diagnostic selector is invalid.`);
|
|
296
|
+
valid = false;
|
|
297
|
+
}
|
|
298
|
+
if (
|
|
299
|
+
!validOptionalText(
|
|
300
|
+
rawDiagnostic.label,
|
|
301
|
+
ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.labelLength,
|
|
302
|
+
)
|
|
303
|
+
) {
|
|
304
|
+
errors.push(`Frame "${frameId}" diagnostic label is invalid.`);
|
|
305
|
+
valid = false;
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
!validOptionalText(
|
|
309
|
+
rawDiagnostic.message,
|
|
310
|
+
ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.messageLength,
|
|
311
|
+
)
|
|
312
|
+
) {
|
|
313
|
+
errors.push(`Frame "${frameId}" diagnostic message is invalid.`);
|
|
314
|
+
valid = false;
|
|
315
|
+
}
|
|
316
|
+
if (!sameViewport(rawDiagnostic.viewport, viewport)) {
|
|
317
|
+
errors.push(`Frame "${frameId}" diagnostic viewport is invalid.`);
|
|
318
|
+
valid = false;
|
|
319
|
+
}
|
|
320
|
+
return valid;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function isQaStage(stage) {
|
|
324
|
+
return (
|
|
325
|
+
typeof stage === 'string' &&
|
|
326
|
+
stage
|
|
327
|
+
.split(/[._:-]/)
|
|
328
|
+
.some((part) => QA_STAGES.has(part.toLowerCase()))
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Creates a deterministic artifact that keeps stable render output separate from
|
|
334
|
+
* capture and QA outcomes. The function performs no I/O.
|
|
335
|
+
*/
|
|
336
|
+
export function createRoutePreviewArtifactV3(input) {
|
|
337
|
+
if (!isPlainRecord(input) || !isPlainRecord(input.captures)) {
|
|
338
|
+
throw new TypeError('Route preview artifact v3 input is invalid.');
|
|
339
|
+
}
|
|
340
|
+
const captureEntries = Object.entries(input.captures).sort(([left], [right]) =>
|
|
341
|
+
left.localeCompare(right),
|
|
342
|
+
);
|
|
343
|
+
if (captureEntries.length > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.frameCount) {
|
|
344
|
+
throw new RangeError('Route preview artifact has too many frames.');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const snapshots = {};
|
|
348
|
+
for (const [frameId, capture] of captureEntries) {
|
|
349
|
+
if (isPlainRecord(capture) && capture.snapshot != null) {
|
|
350
|
+
snapshots[frameId] = capture.snapshot;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const compact = createRoutePreviewArtifactV2({
|
|
354
|
+
namespace: input.namespace,
|
|
355
|
+
...(input.sourceRevision == null
|
|
356
|
+
? {}
|
|
357
|
+
: { sourceRevision: input.sourceRevision }),
|
|
358
|
+
snapshots,
|
|
359
|
+
});
|
|
360
|
+
const screenshotAssets = new Map();
|
|
361
|
+
const frames = [];
|
|
362
|
+
for (const [frameId, rawCapture] of captureEntries) {
|
|
363
|
+
if (!isPlainRecord(rawCapture)) {
|
|
364
|
+
throw new TypeError(`Route preview capture "${frameId}" is invalid.`);
|
|
365
|
+
}
|
|
366
|
+
const diagnostics = Array.isArray(rawCapture.diagnostics)
|
|
367
|
+
? rawCapture.diagnostics.map((diagnostic) =>
|
|
368
|
+
normalizeDiagnostic(diagnostic, rawCapture.viewport),
|
|
369
|
+
)
|
|
370
|
+
: [];
|
|
371
|
+
const screenshot =
|
|
372
|
+
rawCapture.screenshot == null
|
|
373
|
+
? null
|
|
374
|
+
: normalizeScreenshot(rawCapture.screenshot);
|
|
375
|
+
if (screenshot) {
|
|
376
|
+
screenshotAssets.set(screenshot.reference.hash, screenshot.asset);
|
|
377
|
+
}
|
|
378
|
+
frames.push([
|
|
379
|
+
frameId,
|
|
380
|
+
{
|
|
381
|
+
status: rawCapture.status,
|
|
382
|
+
viewport: copyViewport(rawCapture.viewport),
|
|
383
|
+
...(compact.frames[frameId] == null
|
|
384
|
+
? {}
|
|
385
|
+
: { snapshot: compact.frames[frameId] }),
|
|
386
|
+
...(screenshot == null ? {} : { screenshot: screenshot.reference }),
|
|
387
|
+
diagnostics,
|
|
388
|
+
},
|
|
389
|
+
]);
|
|
390
|
+
}
|
|
391
|
+
const artifact = {
|
|
392
|
+
version: 3,
|
|
393
|
+
namespace: compact.namespace,
|
|
394
|
+
...(compact.sourceRevision == null
|
|
395
|
+
? {}
|
|
396
|
+
: { sourceRevision: compact.sourceRevision }),
|
|
397
|
+
assets: {
|
|
398
|
+
head: compact.assets.head,
|
|
399
|
+
stylesheets: compact.assets.stylesheets,
|
|
400
|
+
screenshots: sortedRecord(screenshotAssets),
|
|
401
|
+
},
|
|
402
|
+
frames: Object.fromEntries(frames),
|
|
403
|
+
};
|
|
404
|
+
const report = validateRoutePreviewArtifactV3(artifact);
|
|
405
|
+
if (!report.valid) {
|
|
406
|
+
throw new TypeError(
|
|
407
|
+
`Invalid generated route preview artifact v3: ${report.errors.join(' ')}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
return artifact;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function validateRoutePreviewArtifactV3(bundle) {
|
|
414
|
+
const errors = [];
|
|
415
|
+
const bundleBytes = safeJsonByteLength(bundle);
|
|
416
|
+
if (!isPlainRecord(bundle) || bundle.version !== 3) {
|
|
417
|
+
return {
|
|
418
|
+
valid: false,
|
|
419
|
+
errors: ['Route preview artifact must be a version 3 record.'],
|
|
420
|
+
bytes: bundleBytes,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (!validIdentifier(bundle.namespace)) errors.push('Artifact namespace is invalid.');
|
|
424
|
+
if (!validOptionalIdentifier(bundle.sourceRevision)) {
|
|
425
|
+
errors.push('Artifact source revision is invalid.');
|
|
426
|
+
}
|
|
427
|
+
if (bundleBytes > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.bundleBytes) {
|
|
428
|
+
errors.push('Artifact exceeds the bundle byte limit.');
|
|
429
|
+
}
|
|
430
|
+
if (!isPlainRecord(bundle.assets)) {
|
|
431
|
+
errors.push('Artifact assets must be a record.');
|
|
432
|
+
}
|
|
433
|
+
const screenshotAssets = inspectScreenshotAssets(
|
|
434
|
+
bundle.assets?.screenshots,
|
|
435
|
+
errors,
|
|
436
|
+
);
|
|
437
|
+
const v2Report = validateRoutePreviewArtifactV2(projectArtifactV2(bundle));
|
|
438
|
+
errors.push(...v2Report.errors.map((error) => `Snapshot data: ${error}`));
|
|
439
|
+
|
|
440
|
+
if (!isPlainRecord(bundle.frames)) {
|
|
441
|
+
errors.push('Artifact frames must be a record.');
|
|
442
|
+
return { valid: false, errors, bytes: bundleBytes };
|
|
443
|
+
}
|
|
444
|
+
const frameEntries = Object.entries(bundle.frames);
|
|
445
|
+
if (frameEntries.length > ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.frameCount) {
|
|
446
|
+
errors.push('Artifact has too many frames.');
|
|
447
|
+
}
|
|
448
|
+
const usedScreenshotHashes = new Set();
|
|
449
|
+
for (const [frameId, rawFrame] of frameEntries) {
|
|
450
|
+
if (!validIdentifier(frameId)) {
|
|
451
|
+
errors.push(`Frame identifier "${frameId}" is invalid.`);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (
|
|
455
|
+
!isPlainRecord(rawFrame) ||
|
|
456
|
+
!STATUSES.has(rawFrame.status) ||
|
|
457
|
+
!validViewport(rawFrame.viewport)
|
|
458
|
+
) {
|
|
459
|
+
errors.push(`Frame "${frameId}" status or viewport is invalid.`);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const diagnostics = rawFrame.diagnostics;
|
|
463
|
+
if (
|
|
464
|
+
!Array.isArray(diagnostics) ||
|
|
465
|
+
diagnostics.length >
|
|
466
|
+
ROUTE_PREVIEW_ARTIFACT_V3_LIMITS.diagnosticCountPerFrame
|
|
467
|
+
) {
|
|
468
|
+
errors.push(`Frame "${frameId}" diagnostics are invalid.`);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
for (const diagnostic of diagnostics) {
|
|
472
|
+
inspectDiagnostic(diagnostic, frameId, rawFrame.viewport, errors);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let hasScreenshot = false;
|
|
476
|
+
if (rawFrame.screenshot !== undefined) {
|
|
477
|
+
const reference = rawFrame.screenshot;
|
|
478
|
+
if (
|
|
479
|
+
!isPlainRecord(reference) ||
|
|
480
|
+
typeof reference.hash !== 'string' ||
|
|
481
|
+
!HASH_PATTERN.test(reference.hash) ||
|
|
482
|
+
!MEDIA_TYPES.has(reference.mediaType) ||
|
|
483
|
+
!Number.isInteger(reference.width) ||
|
|
484
|
+
!Number.isInteger(reference.height)
|
|
485
|
+
) {
|
|
486
|
+
errors.push(`Frame "${frameId}" screenshot reference is invalid.`);
|
|
487
|
+
} else {
|
|
488
|
+
const asset = screenshotAssets.get(reference.hash);
|
|
489
|
+
if (!asset) {
|
|
490
|
+
errors.push(`Frame "${frameId}" references a missing screenshot asset.`);
|
|
491
|
+
} else if (
|
|
492
|
+
asset.mediaType !== reference.mediaType ||
|
|
493
|
+
asset.width !== reference.width ||
|
|
494
|
+
asset.height !== reference.height
|
|
495
|
+
) {
|
|
496
|
+
errors.push(`Frame "${frameId}" screenshot metadata does not match its asset.`);
|
|
497
|
+
} else {
|
|
498
|
+
hasScreenshot = true;
|
|
499
|
+
usedScreenshotHashes.add(reference.hash);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
const hasSnapshot = rawFrame.snapshot !== undefined;
|
|
504
|
+
if (rawFrame.status === 'ready') {
|
|
505
|
+
if (!hasSnapshot || !hasScreenshot) {
|
|
506
|
+
errors.push(`Frame "${frameId}" ready output is incomplete.`);
|
|
507
|
+
}
|
|
508
|
+
if (diagnostics.length !== 0) {
|
|
509
|
+
errors.push(`Frame "${frameId}" ready output cannot contain diagnostics.`);
|
|
510
|
+
}
|
|
511
|
+
} else if (rawFrame.status === 'rendered-with-qa-failure') {
|
|
512
|
+
if (!hasSnapshot || !hasScreenshot) {
|
|
513
|
+
errors.push(`Frame "${frameId}" QA failure must preserve stable output.`);
|
|
514
|
+
}
|
|
515
|
+
if (
|
|
516
|
+
diagnostics.length === 0 ||
|
|
517
|
+
!diagnostics.some((diagnostic) =>
|
|
518
|
+
isPlainRecord(diagnostic) ? isQaStage(diagnostic.stage) : false,
|
|
519
|
+
)
|
|
520
|
+
) {
|
|
521
|
+
errors.push(`Frame "${frameId}" QA failure requires a QA diagnostic.`);
|
|
522
|
+
}
|
|
523
|
+
} else if (diagnostics.length === 0) {
|
|
524
|
+
errors.push(`Frame "${frameId}" capture error requires a diagnostic.`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const hash of screenshotAssets.keys()) {
|
|
528
|
+
if (!usedScreenshotHashes.has(hash)) {
|
|
529
|
+
errors.push(`Screenshot asset "${hash}" is unreferenced.`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return { valid: errors.length === 0, errors, bytes: bundleBytes };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Validates every compact route-preview artifact version accepted by the
|
|
537
|
+
* server runtime. Version-specific validators remain available for callers
|
|
538
|
+
* that intentionally require one exact contract.
|
|
539
|
+
*/
|
|
540
|
+
export function validateRoutePreviewArtifactBundle(bundle) {
|
|
541
|
+
return bundle?.version === 3
|
|
542
|
+
? validateRoutePreviewArtifactV3(bundle)
|
|
543
|
+
: validateRoutePreviewArtifactV2(bundle);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function reconstructRoutePreviewArtifactSnapshotV3(bundle, frameId) {
|
|
547
|
+
const report = validateRoutePreviewArtifactV3(bundle);
|
|
548
|
+
if (!report.valid) {
|
|
549
|
+
throw new TypeError(
|
|
550
|
+
`Invalid route preview artifact v3: ${report.errors.join(' ')}`,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
const frame = bundle.frames[frameId];
|
|
554
|
+
if (!frame?.snapshot) return null;
|
|
555
|
+
return reconstructRoutePreviewArtifactSnapshot(projectArtifactV2(bundle), frameId);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function reconstructRoutePreviewArtifactSnapshotsV3(bundle) {
|
|
559
|
+
const report = validateRoutePreviewArtifactV3(bundle);
|
|
560
|
+
if (!report.valid) {
|
|
561
|
+
throw new TypeError(
|
|
562
|
+
`Invalid route preview artifact v3: ${report.errors.join(' ')}`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
return reconstructRoutePreviewArtifactSnapshots(projectArtifactV2(bundle));
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function reconstructRoutePreviewArtifactScreenshotV3(bundle, frameId) {
|
|
569
|
+
const report = validateRoutePreviewArtifactV3(bundle);
|
|
570
|
+
if (!report.valid) {
|
|
571
|
+
throw new TypeError(
|
|
572
|
+
`Invalid route preview artifact v3: ${report.errors.join(' ')}`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
const reference = bundle.frames[frameId]?.screenshot;
|
|
576
|
+
if (!reference) return null;
|
|
577
|
+
const asset = bundle.assets.screenshots[reference.hash];
|
|
578
|
+
return {
|
|
579
|
+
mediaType: asset.mediaType,
|
|
580
|
+
width: asset.width,
|
|
581
|
+
height: asset.height,
|
|
582
|
+
bytes: Buffer.from(asset.data, 'base64'),
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { lstatSync, readFileSync, readlinkSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const MAX_GIT_OUTPUT_BYTES = 256 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
function git(root, args, encoding = 'utf8') {
|
|
9
|
+
return execFileSync('git', ['-C', root, ...args], {
|
|
10
|
+
encoding,
|
|
11
|
+
maxBuffer: MAX_GIT_OUTPUT_BYTES,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function nullSeparated(value) {
|
|
16
|
+
return value
|
|
17
|
+
.toString('utf8')
|
|
18
|
+
.split('\0')
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns a source identity that is a plain commit for clean worktrees and a
|
|
24
|
+
* deterministic commit-scoped fingerprint for dirty worktrees.
|
|
25
|
+
*
|
|
26
|
+
* Ignored files are intentionally excluded. Hosts should represent runtime
|
|
27
|
+
* configuration separately in their capture recipe.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveGitSourceState(root = process.cwd()) {
|
|
30
|
+
const resolvedRoot = path.resolve(root);
|
|
31
|
+
const commit = git(resolvedRoot, ['rev-parse', 'HEAD']).trim();
|
|
32
|
+
const status = git(
|
|
33
|
+
resolvedRoot,
|
|
34
|
+
['status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
35
|
+
'buffer',
|
|
36
|
+
);
|
|
37
|
+
if (status.byteLength === 0) {
|
|
38
|
+
return {
|
|
39
|
+
commit,
|
|
40
|
+
revision: commit,
|
|
41
|
+
dirty: false,
|
|
42
|
+
fingerprint: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const hash = createHash('sha256');
|
|
47
|
+
hash.update('pygmalion-source-state-v1\0');
|
|
48
|
+
hash.update(commit);
|
|
49
|
+
hash.update('\0');
|
|
50
|
+
hash.update(status);
|
|
51
|
+
hash.update(
|
|
52
|
+
git(resolvedRoot, ['diff', '--binary', 'HEAD', '--'], 'buffer'),
|
|
53
|
+
);
|
|
54
|
+
const untracked = nullSeparated(
|
|
55
|
+
git(
|
|
56
|
+
resolvedRoot,
|
|
57
|
+
['ls-files', '--others', '--exclude-standard', '-z'],
|
|
58
|
+
'buffer',
|
|
59
|
+
),
|
|
60
|
+
).sort((left, right) => left.localeCompare(right));
|
|
61
|
+
for (const relativePath of untracked) {
|
|
62
|
+
const absolutePath = path.resolve(resolvedRoot, relativePath);
|
|
63
|
+
if (
|
|
64
|
+
absolutePath !== resolvedRoot &&
|
|
65
|
+
!absolutePath.startsWith(`${resolvedRoot}${path.sep}`)
|
|
66
|
+
) {
|
|
67
|
+
throw new Error('Git returned an untracked path outside the repository.');
|
|
68
|
+
}
|
|
69
|
+
const stat = lstatSync(absolutePath);
|
|
70
|
+
hash.update('\0untracked\0');
|
|
71
|
+
hash.update(relativePath);
|
|
72
|
+
hash.update('\0');
|
|
73
|
+
if (stat.isSymbolicLink()) hash.update(readlinkSync(absolutePath));
|
|
74
|
+
else if (stat.isFile()) hash.update(readFileSync(absolutePath));
|
|
75
|
+
}
|
|
76
|
+
const fingerprint = hash.digest('hex');
|
|
77
|
+
return {
|
|
78
|
+
commit,
|
|
79
|
+
revision: `${commit}-dirty-${fingerprint.slice(0, 16)}`,
|
|
80
|
+
dirty: true,
|
|
81
|
+
fingerprint,
|
|
82
|
+
};
|
|
83
|
+
}
|