@pygmalionjs/pygmalion 0.2.7 → 0.2.8
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/dist-lib/{App-D_6vGXDa.js → App-XRuc9KYj.js} +6336 -5213
- package/dist-lib/pygmalion.js +223 -172
- package/dist-lib/style.css +1 -1
- package/dist-lib/testing.js +1 -1
- package/node/route-preview-artifact-v2.mjs +483 -0
- package/node/storyboard-canonical.mjs +410 -0
- package/node/storyboard-environment.mjs +1507 -0
- package/node/vite.mjs +55 -2
- package/package.json +4 -1
- package/types.d.ts +88 -3
- package/vite.d.ts +262 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { availableParallelism, totalmem } from 'node:os';
|
|
3
|
+
|
|
4
|
+
export const PYGMALION_STORYBOARD_CANONICAL_CONTROL =
|
|
5
|
+
'/__pygmalion-storyboard/canonicalize';
|
|
6
|
+
|
|
7
|
+
const MAX_SNAPSHOT_BYTES = 12 * 1024 * 1024;
|
|
8
|
+
const AUTO_ID_PATTERN =
|
|
9
|
+
/(?:_r_[A-Za-z0-9_:-]+|radix-[A-Za-z0-9_:-]+|react-aria-[A-Za-z0-9_:-]+)/g;
|
|
10
|
+
const TRANSIENT_ATTRIBUTE_PATTERN =
|
|
11
|
+
/\s(?:data-pygmalion-source|data-pygmalion-slot-count|data-vite-dev-id|data-reactid|data-reactroot|nonce)=(?:"[^"]*"|'[^']*')/gi;
|
|
12
|
+
const FROZEN_STYLE_PATTERN =
|
|
13
|
+
/<style(?:\s[^>]*)?>\s*\*,\*::before,\*::after\{animation:none!important;transition:none!important;caret-color:transparent!important\}html,body\{pointer-events:none!important\}\s*<\/style>/gi;
|
|
14
|
+
|
|
15
|
+
function safeSnapshot(snapshot) {
|
|
16
|
+
if (
|
|
17
|
+
typeof snapshot !== 'string' ||
|
|
18
|
+
snapshot.length === 0 ||
|
|
19
|
+
Buffer.byteLength(snapshot, 'utf8') > MAX_SNAPSHOT_BYTES
|
|
20
|
+
) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return !(
|
|
24
|
+
/<(?:script|iframe|object|embed)\b/i.test(snapshot) ||
|
|
25
|
+
/\son[a-z]+\s*=/i.test(snapshot) ||
|
|
26
|
+
/(?:href|src)\s*=\s*["']?\s*javascript:/i.test(snapshot)
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function replaceGeneratedIds(snapshot) {
|
|
31
|
+
const replacements = new Map();
|
|
32
|
+
let index = 0;
|
|
33
|
+
return snapshot.replace(AUTO_ID_PATTERN, (value) => {
|
|
34
|
+
if (!replacements.has(value)) {
|
|
35
|
+
replacements.set(value, `__pygmalion_auto_id_${index}`);
|
|
36
|
+
index += 1;
|
|
37
|
+
}
|
|
38
|
+
return replacements.get(value);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Removes capture-runtime metadata while preserving authored DOM, text, state,
|
|
44
|
+
* styles, and accessibility attributes.
|
|
45
|
+
*/
|
|
46
|
+
export function normalizeStoryboardSnapshot(snapshot) {
|
|
47
|
+
if (!safeSnapshot(snapshot)) return null;
|
|
48
|
+
const normalized = snapshot
|
|
49
|
+
.replace(/\r\n?/g, '\n')
|
|
50
|
+
.replace(/<!doctype\s+html>/i, '<!doctype html>')
|
|
51
|
+
.replace(
|
|
52
|
+
/<meta[^>]+http-equiv=(?:"Content-Security-Policy"|'Content-Security-Policy')[^>]*>/gi,
|
|
53
|
+
'',
|
|
54
|
+
)
|
|
55
|
+
.replace(/<base(?:\s[^>]*)?>/gi, '')
|
|
56
|
+
.replace(FROZEN_STYLE_PATTERN, '')
|
|
57
|
+
.replace(TRANSIENT_ATTRIBUTE_PATTERN, '')
|
|
58
|
+
.replace(
|
|
59
|
+
/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?/gi,
|
|
60
|
+
'http://__pygmalion_origin__',
|
|
61
|
+
)
|
|
62
|
+
.replace(/([?&])__pygmalion_environment=[^"'&\s>]*/gi, '$1')
|
|
63
|
+
.replace(/\?&/g, '?')
|
|
64
|
+
.replace(/[?&](?=["'\s>])/g, '')
|
|
65
|
+
.replace(/>\s+</g, '><')
|
|
66
|
+
.trim();
|
|
67
|
+
return replaceGeneratedIds(normalized);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function fingerprintStoryboardSnapshot(snapshot) {
|
|
71
|
+
const normalized = normalizeStoryboardSnapshot(snapshot);
|
|
72
|
+
if (normalized == null) return null;
|
|
73
|
+
return createHash('sha256').update(normalized).digest('hex');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
77
|
+
const candidate = Number.isFinite(value) ? Math.floor(value) : fallback;
|
|
78
|
+
return Math.max(minimum, Math.min(maximum, candidate));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function recommendedStoryboardExecutionConcurrency(capabilities = {}) {
|
|
82
|
+
const parallelism = boundedInteger(
|
|
83
|
+
capabilities.parallelism,
|
|
84
|
+
availableParallelism(),
|
|
85
|
+
1,
|
|
86
|
+
128,
|
|
87
|
+
);
|
|
88
|
+
const totalMemoryBytes =
|
|
89
|
+
Number.isFinite(capabilities.totalMemoryBytes) &&
|
|
90
|
+
capabilities.totalMemoryBytes > 0
|
|
91
|
+
? capabilities.totalMemoryBytes
|
|
92
|
+
: totalmem();
|
|
93
|
+
const memoryLanes = Math.max(
|
|
94
|
+
8,
|
|
95
|
+
Math.floor(totalMemoryBytes / (512 * 1024 * 1024)),
|
|
96
|
+
);
|
|
97
|
+
return Math.min(48, Math.max(8, Math.min(parallelism * 2, memoryLanes)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Executes discovered candidates with bounded parallelism.
|
|
102
|
+
*
|
|
103
|
+
* The capture adapter owns browser automation. It receives the complete
|
|
104
|
+
* candidate and an AbortSignal, and returns a frozen DOM snapshot.
|
|
105
|
+
*/
|
|
106
|
+
export async function executeStoryboardCandidates(candidates, capture, options = {}) {
|
|
107
|
+
if (typeof capture !== 'function') {
|
|
108
|
+
throw new TypeError('Storyboard candidate execution requires a capture function.');
|
|
109
|
+
}
|
|
110
|
+
const queue = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
|
113
|
+
const normalized = normalizedCandidate(candidate);
|
|
114
|
+
if (!normalized || seen.has(normalized.id)) continue;
|
|
115
|
+
seen.add(normalized.id);
|
|
116
|
+
queue.push(candidate);
|
|
117
|
+
}
|
|
118
|
+
const concurrency = boundedInteger(
|
|
119
|
+
options.concurrency,
|
|
120
|
+
recommendedStoryboardExecutionConcurrency(),
|
|
121
|
+
1,
|
|
122
|
+
64,
|
|
123
|
+
);
|
|
124
|
+
const timeoutMs = boundedInteger(options.timeoutMs, 30_000, 100, 300_000);
|
|
125
|
+
const executions = new Array(queue.length);
|
|
126
|
+
let nextIndex = 0;
|
|
127
|
+
|
|
128
|
+
async function worker() {
|
|
129
|
+
while (nextIndex < queue.length) {
|
|
130
|
+
const index = nextIndex;
|
|
131
|
+
nextIndex += 1;
|
|
132
|
+
const candidate = queue[index];
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
let timer;
|
|
135
|
+
try {
|
|
136
|
+
const snapshot = await Promise.race([
|
|
137
|
+
Promise.resolve(
|
|
138
|
+
capture(candidate, {
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
index,
|
|
141
|
+
total: queue.length,
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
new Promise((_, reject) => {
|
|
145
|
+
timer = setTimeout(() => {
|
|
146
|
+
controller.abort();
|
|
147
|
+
reject(
|
|
148
|
+
new Error(`Candidate capture timed out after ${timeoutMs}ms.`),
|
|
149
|
+
);
|
|
150
|
+
}, timeoutMs);
|
|
151
|
+
}),
|
|
152
|
+
]);
|
|
153
|
+
executions[index] = {
|
|
154
|
+
candidateId: candidate.id,
|
|
155
|
+
status: 'captured',
|
|
156
|
+
snapshot,
|
|
157
|
+
};
|
|
158
|
+
} catch (error) {
|
|
159
|
+
executions[index] = {
|
|
160
|
+
candidateId: candidate.id,
|
|
161
|
+
status: 'failed',
|
|
162
|
+
error: error instanceof Error ? error.message : String(error),
|
|
163
|
+
};
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await Promise.all(
|
|
171
|
+
Array.from(
|
|
172
|
+
{ length: Math.min(concurrency, queue.length) },
|
|
173
|
+
() => worker(),
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
return executions;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizedCandidate(candidate) {
|
|
180
|
+
if (
|
|
181
|
+
!candidate ||
|
|
182
|
+
typeof candidate !== 'object' ||
|
|
183
|
+
typeof candidate.id !== 'string' ||
|
|
184
|
+
!candidate.id.trim() ||
|
|
185
|
+
typeof candidate.route !== 'string' ||
|
|
186
|
+
typeof candidate.scenarioId !== 'string'
|
|
187
|
+
) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
id: candidate.id.trim(),
|
|
192
|
+
route: candidate.route,
|
|
193
|
+
scenarioId: candidate.scenarioId,
|
|
194
|
+
evidenceIds: Array.isArray(candidate.evidenceIds)
|
|
195
|
+
? [...new Set(candidate.evidenceIds.filter((id) => typeof id === 'string'))].sort()
|
|
196
|
+
: [],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function warning(code, severity, candidate, message) {
|
|
201
|
+
return {
|
|
202
|
+
code,
|
|
203
|
+
severity,
|
|
204
|
+
candidateId: candidate.id,
|
|
205
|
+
route: candidate.route,
|
|
206
|
+
scenarioId: candidate.scenarioId,
|
|
207
|
+
evidenceIds: candidate.evidenceIds,
|
|
208
|
+
message,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Collapses captured candidate results into canonical frames.
|
|
214
|
+
*
|
|
215
|
+
* The discovered candidate list is authoritative. Unknown execution results
|
|
216
|
+
* are reported and ignored, while every missing or failed candidate remains
|
|
217
|
+
* visible in coverage.
|
|
218
|
+
*/
|
|
219
|
+
export function canonicalizeStoryboardExecutions(candidates, executions) {
|
|
220
|
+
const expected = [];
|
|
221
|
+
const candidateById = new Map();
|
|
222
|
+
for (const rawCandidate of Array.isArray(candidates) ? candidates : []) {
|
|
223
|
+
const candidate = normalizedCandidate(rawCandidate);
|
|
224
|
+
if (!candidate || candidateById.has(candidate.id)) continue;
|
|
225
|
+
candidateById.set(candidate.id, candidate);
|
|
226
|
+
expected.push(candidate);
|
|
227
|
+
}
|
|
228
|
+
expected.sort((a, b) => a.id.localeCompare(b.id));
|
|
229
|
+
|
|
230
|
+
const executionByCandidate = new Map();
|
|
231
|
+
const warnings = [];
|
|
232
|
+
for (const execution of Array.isArray(executions) ? executions : []) {
|
|
233
|
+
const candidateId =
|
|
234
|
+
execution && typeof execution === 'object' && typeof execution.candidateId === 'string'
|
|
235
|
+
? execution.candidateId.trim()
|
|
236
|
+
: '';
|
|
237
|
+
const candidate = candidateById.get(candidateId);
|
|
238
|
+
if (!candidate) {
|
|
239
|
+
if (candidateId) {
|
|
240
|
+
warnings.push({
|
|
241
|
+
code: 'unknown-candidate',
|
|
242
|
+
severity: 'error',
|
|
243
|
+
candidateId,
|
|
244
|
+
route: '',
|
|
245
|
+
scenarioId: '',
|
|
246
|
+
evidenceIds: [],
|
|
247
|
+
message: 'Execution result does not match a discovered candidate.',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (executionByCandidate.has(candidateId)) {
|
|
253
|
+
warnings.push(
|
|
254
|
+
warning(
|
|
255
|
+
'duplicate-execution',
|
|
256
|
+
'error',
|
|
257
|
+
candidate,
|
|
258
|
+
'Multiple execution results were submitted for one candidate.',
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
executionByCandidate.set(candidateId, execution);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const captured = [];
|
|
267
|
+
let failed = 0;
|
|
268
|
+
let missing = 0;
|
|
269
|
+
for (const candidate of expected) {
|
|
270
|
+
const execution = executionByCandidate.get(candidate.id);
|
|
271
|
+
if (!execution) {
|
|
272
|
+
missing += 1;
|
|
273
|
+
warnings.push(
|
|
274
|
+
warning(
|
|
275
|
+
'missing-execution',
|
|
276
|
+
'error',
|
|
277
|
+
candidate,
|
|
278
|
+
'Discovered candidate was not executed.',
|
|
279
|
+
),
|
|
280
|
+
);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (execution.status !== 'captured') {
|
|
284
|
+
failed += 1;
|
|
285
|
+
warnings.push(
|
|
286
|
+
warning(
|
|
287
|
+
'capture-failed',
|
|
288
|
+
'error',
|
|
289
|
+
candidate,
|
|
290
|
+
typeof execution.error === 'string' && execution.error.trim()
|
|
291
|
+
? execution.error.trim()
|
|
292
|
+
: 'Candidate capture failed.',
|
|
293
|
+
),
|
|
294
|
+
);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const normalizedSnapshot = normalizeStoryboardSnapshot(execution.snapshot);
|
|
298
|
+
if (normalizedSnapshot == null) {
|
|
299
|
+
failed += 1;
|
|
300
|
+
warnings.push(
|
|
301
|
+
warning(
|
|
302
|
+
'invalid-snapshot',
|
|
303
|
+
'error',
|
|
304
|
+
candidate,
|
|
305
|
+
'Candidate returned an empty, oversized, or unsafe snapshot.',
|
|
306
|
+
),
|
|
307
|
+
);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const fingerprint = createHash('sha256')
|
|
311
|
+
.update(normalizedSnapshot)
|
|
312
|
+
.digest('hex');
|
|
313
|
+
captured.push({
|
|
314
|
+
candidate,
|
|
315
|
+
fingerprint,
|
|
316
|
+
snapshot: normalizedSnapshot,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const baselineByRoute = new Map();
|
|
321
|
+
for (const item of captured) {
|
|
322
|
+
if (item.candidate.scenarioId === 'environment:baseline') {
|
|
323
|
+
baselineByRoute.set(item.candidate.route, item.fingerprint);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
let unreproduced = 0;
|
|
328
|
+
for (const item of captured) {
|
|
329
|
+
if (item.candidate.scenarioId === 'environment:baseline') continue;
|
|
330
|
+
const baseline = baselineByRoute.get(item.candidate.route);
|
|
331
|
+
if (baseline && baseline === item.fingerprint) {
|
|
332
|
+
unreproduced += 1;
|
|
333
|
+
warnings.push(
|
|
334
|
+
warning(
|
|
335
|
+
'branch-not-observed',
|
|
336
|
+
'warning',
|
|
337
|
+
item.candidate,
|
|
338
|
+
'Captured branch matched the route baseline and produced no distinct DOM state.',
|
|
339
|
+
),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const framesByFingerprint = new Map();
|
|
345
|
+
const aliases = {};
|
|
346
|
+
for (const item of captured) {
|
|
347
|
+
let frame = framesByFingerprint.get(item.fingerprint);
|
|
348
|
+
if (!frame) {
|
|
349
|
+
frame = {
|
|
350
|
+
id: `frame:${item.fingerprint.slice(0, 16)}`,
|
|
351
|
+
fingerprint: item.fingerprint,
|
|
352
|
+
snapshot: item.snapshot,
|
|
353
|
+
candidateIds: [],
|
|
354
|
+
routes: new Set(),
|
|
355
|
+
scenarioIds: new Set(),
|
|
356
|
+
evidenceIds: new Set(),
|
|
357
|
+
};
|
|
358
|
+
framesByFingerprint.set(item.fingerprint, frame);
|
|
359
|
+
}
|
|
360
|
+
frame.candidateIds.push(item.candidate.id);
|
|
361
|
+
frame.routes.add(item.candidate.route);
|
|
362
|
+
frame.scenarioIds.add(item.candidate.scenarioId);
|
|
363
|
+
for (const evidenceId of item.candidate.evidenceIds) {
|
|
364
|
+
frame.evidenceIds.add(evidenceId);
|
|
365
|
+
}
|
|
366
|
+
aliases[item.candidate.id] = frame.id;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const frames = [...framesByFingerprint.values()]
|
|
370
|
+
.map((frame) => ({
|
|
371
|
+
id: frame.id,
|
|
372
|
+
fingerprint: frame.fingerprint,
|
|
373
|
+
snapshot: frame.snapshot,
|
|
374
|
+
candidateIds: frame.candidateIds.sort(),
|
|
375
|
+
routes: [...frame.routes].sort(),
|
|
376
|
+
scenarioIds: [...frame.scenarioIds].sort(),
|
|
377
|
+
evidenceIds: [...frame.evidenceIds].sort(),
|
|
378
|
+
}))
|
|
379
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
380
|
+
const sortedAliases = Object.fromEntries(
|
|
381
|
+
Object.entries(aliases).sort(([a], [b]) => a.localeCompare(b)),
|
|
382
|
+
);
|
|
383
|
+
warnings.sort((a, b) =>
|
|
384
|
+
`${a.candidateId}:${a.code}`.localeCompare(`${b.candidateId}:${b.code}`),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
const capturedCount = captured.length;
|
|
388
|
+
const duplicates = Math.max(0, capturedCount - frames.length);
|
|
389
|
+
return {
|
|
390
|
+
version: 1,
|
|
391
|
+
frames,
|
|
392
|
+
aliases: sortedAliases,
|
|
393
|
+
warnings,
|
|
394
|
+
coverage: {
|
|
395
|
+
expected: expected.length,
|
|
396
|
+
executed: expected.length - missing,
|
|
397
|
+
captured: capturedCount,
|
|
398
|
+
canonical: frames.length,
|
|
399
|
+
duplicates,
|
|
400
|
+
failed,
|
|
401
|
+
missing,
|
|
402
|
+
unreproduced,
|
|
403
|
+
complete:
|
|
404
|
+
failed === 0 &&
|
|
405
|
+
missing === 0 &&
|
|
406
|
+
unreproduced === 0 &&
|
|
407
|
+
!warnings.some((item) => item.severity === 'error'),
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|