@pygmalionjs/pygmalion 0.2.10 → 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.
- package/README.ko.md +36 -10
- package/README.md +36 -10
- package/dist-lib/{App-BDUGQN1Y.js → App-B0w9z2Br.js} +8004 -6025
- package/dist-lib/pygmalion.js +2043 -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,1165 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_VIEWPORT = Object.freeze({ width: 1280, height: 800 });
|
|
4
|
+
const QA_FAILURE_STAGES = new Set(['interaction', 'assertion']);
|
|
5
|
+
const STORYBOARD_ENVIRONMENT_QUERY = '__pygmalion_environment';
|
|
6
|
+
const FROZEN_STYLE =
|
|
7
|
+
'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}';
|
|
8
|
+
const DOM_STABLE_ATTRIBUTES = new Set([
|
|
9
|
+
'type',
|
|
10
|
+
'name',
|
|
11
|
+
'role',
|
|
12
|
+
'class',
|
|
13
|
+
'href',
|
|
14
|
+
'src',
|
|
15
|
+
'alt',
|
|
16
|
+
'title',
|
|
17
|
+
'aria-label',
|
|
18
|
+
'aria-expanded',
|
|
19
|
+
'aria-hidden',
|
|
20
|
+
'aria-pressed',
|
|
21
|
+
'aria-selected',
|
|
22
|
+
'aria-checked',
|
|
23
|
+
'aria-disabled',
|
|
24
|
+
'disabled',
|
|
25
|
+
'checked',
|
|
26
|
+
'selected',
|
|
27
|
+
'open',
|
|
28
|
+
'data-testid',
|
|
29
|
+
'data-state',
|
|
30
|
+
'data-scenario',
|
|
31
|
+
'data-kind',
|
|
32
|
+
'data-card-key',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export const STORYBOARD_CAPTURE_STATUSES = Object.freeze({
|
|
36
|
+
ready: 'ready',
|
|
37
|
+
qaFailure: 'rendered-with-qa-failure',
|
|
38
|
+
captureError: 'capture-error',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export class StoryboardCaptureStageError extends Error {
|
|
42
|
+
constructor(stage, error, details = {}) {
|
|
43
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
44
|
+
super(message, { cause: error });
|
|
45
|
+
this.name = 'StoryboardCaptureStageError';
|
|
46
|
+
this.stage = stage;
|
|
47
|
+
this.details = details;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function messageOf(error) {
|
|
52
|
+
return error instanceof Error ? error.message : String(error);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function boundedDiagnosticText(value, maximumLength) {
|
|
56
|
+
if (typeof value !== 'string') return undefined;
|
|
57
|
+
const normalized = value
|
|
58
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
if (!normalized) return undefined;
|
|
61
|
+
return normalized.length <= maximumLength
|
|
62
|
+
? normalized
|
|
63
|
+
: `${normalized.slice(0, Math.max(0, maximumLength - 1))}…`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function asPositiveInteger(value, fallback) {
|
|
67
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function captureViewport(screenCase) {
|
|
71
|
+
return {
|
|
72
|
+
width: asPositiveInteger(screenCase?.width, DEFAULT_VIEWPORT.width),
|
|
73
|
+
height: asPositiveInteger(screenCase?.height, DEFAULT_VIEWPORT.height),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function throwIfAborted(signal) {
|
|
78
|
+
signal?.throwIfAborted?.();
|
|
79
|
+
if (signal?.aborted) {
|
|
80
|
+
throw signal.reason instanceof Error
|
|
81
|
+
? signal.reason
|
|
82
|
+
: new Error('Storyboard capture was aborted.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function atCaptureStage(stage, task, details = {}) {
|
|
87
|
+
try {
|
|
88
|
+
return await task();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof StoryboardCaptureStageError) throw error;
|
|
91
|
+
throw new StoryboardCaptureStageError(stage, error, details);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function resolveStoryboardCaptureRoute(screenCase) {
|
|
96
|
+
return (
|
|
97
|
+
screenCase?.route ??
|
|
98
|
+
screenCase?.coverageRoutes?.[0] ??
|
|
99
|
+
screenCase?.path ??
|
|
100
|
+
null
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves an application route without dropping a proxy prefix in the base URL.
|
|
106
|
+
*/
|
|
107
|
+
export function resolveStoryboardCaptureUrl(baseUrl, route, environment) {
|
|
108
|
+
const base = new URL(baseUrl);
|
|
109
|
+
const routeUrl = new URL(String(route ?? ''), 'http://pygmalion-route.local');
|
|
110
|
+
const prefix = base.pathname.endsWith('/')
|
|
111
|
+
? base.pathname
|
|
112
|
+
: `${base.pathname}/`;
|
|
113
|
+
base.pathname = `${prefix}${routeUrl.pathname.replace(/^\/+/, '')}`.replace(
|
|
114
|
+
/\/{2,}/g,
|
|
115
|
+
'/',
|
|
116
|
+
);
|
|
117
|
+
base.search = routeUrl.search;
|
|
118
|
+
base.hash = routeUrl.hash;
|
|
119
|
+
if (
|
|
120
|
+
environment &&
|
|
121
|
+
typeof environment === 'object' &&
|
|
122
|
+
!Array.isArray(environment) &&
|
|
123
|
+
Object.keys(environment).length > 0
|
|
124
|
+
) {
|
|
125
|
+
base.searchParams.set(
|
|
126
|
+
STORYBOARD_ENVIRONMENT_QUERY,
|
|
127
|
+
Buffer.from(JSON.stringify(environment), 'utf8').toString('base64url'),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return base.href;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveStoryboardFixedTime(preset) {
|
|
134
|
+
if (preset?.timers?.mode !== 'fixed') return null;
|
|
135
|
+
const value = preset.timers.now;
|
|
136
|
+
const time =
|
|
137
|
+
typeof value === 'string' || typeof value === 'number'
|
|
138
|
+
? new Date(value)
|
|
139
|
+
: null;
|
|
140
|
+
if (!time || Number.isNaN(time.getTime())) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
'A valid timers.now value is required when timers.mode is fixed.',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return time.toISOString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function hashStoryboardCapture(value) {
|
|
149
|
+
return createHash('sha256').update(value).digest('hex');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function normalizeCapturedClass(value) {
|
|
153
|
+
return value
|
|
154
|
+
.split(/\s+/)
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((name) =>
|
|
157
|
+
name
|
|
158
|
+
.replace(/__[A-Za-z0-9_-]+_[A-Za-z0-9]{5,}$/g, '__{hash}')
|
|
159
|
+
.replace(/_[A-Za-z0-9]{6,}$/g, '_{hash}'),
|
|
160
|
+
)
|
|
161
|
+
.sort()
|
|
162
|
+
.join(' ');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeCapturedUrl(value) {
|
|
166
|
+
if (/^(?:blob:|data:)/.test(value)) {
|
|
167
|
+
return `{${value.split(':', 1)[0]}}`;
|
|
168
|
+
}
|
|
169
|
+
return value
|
|
170
|
+
.replace(/[?#].*$/, '')
|
|
171
|
+
.replace(/\/[a-f0-9]{16,}(?=\/|$)/gi, '/{id}');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeCapturedAttribute(name, value) {
|
|
175
|
+
if (name === 'class') return normalizeCapturedClass(value);
|
|
176
|
+
if (name === 'href' || name === 'src') return normalizeCapturedUrl(value);
|
|
177
|
+
if (name === 'style') {
|
|
178
|
+
return value
|
|
179
|
+
.split(';')
|
|
180
|
+
.map((declaration) => declaration.split(':', 1)[0]?.trim())
|
|
181
|
+
.filter(Boolean)
|
|
182
|
+
.sort()
|
|
183
|
+
.join(';');
|
|
184
|
+
}
|
|
185
|
+
if (!DOM_STABLE_ATTRIBUTES.has(name)) return '{value}';
|
|
186
|
+
return value
|
|
187
|
+
.replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, '{uuid}')
|
|
188
|
+
.replace(/\b\d{10,}\b/g, '{number}')
|
|
189
|
+
.replace(/\b[a-f0-9]{20,}\b/gi, '{hash}');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function normalizedCapturedNode(node) {
|
|
193
|
+
if (!node) return '';
|
|
194
|
+
if (node.type === 'text') return '#text';
|
|
195
|
+
const attributes = [...(node.attributes ?? [])]
|
|
196
|
+
.map(([name, value]) => [
|
|
197
|
+
name.toLowerCase(),
|
|
198
|
+
normalizeCapturedAttribute(name.toLowerCase(), value),
|
|
199
|
+
])
|
|
200
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
201
|
+
const children = (node.children ?? [])
|
|
202
|
+
.map(normalizedCapturedNode)
|
|
203
|
+
.filter(Boolean);
|
|
204
|
+
return {
|
|
205
|
+
tag: String(node.tag ?? '').toLowerCase(),
|
|
206
|
+
attributes,
|
|
207
|
+
children,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Produces a stable structural DOM identity without volatile text, IDs, or
|
|
213
|
+
* generated class suffixes.
|
|
214
|
+
*/
|
|
215
|
+
export function normalizeStoryboardDomStructure(node) {
|
|
216
|
+
return JSON.stringify(normalizedCapturedNode(node));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function createStoryboardArtifactRecord({
|
|
220
|
+
screenCase,
|
|
221
|
+
domTree,
|
|
222
|
+
screenshot,
|
|
223
|
+
route,
|
|
224
|
+
}) {
|
|
225
|
+
return {
|
|
226
|
+
id: screenCase.id,
|
|
227
|
+
scenarioIds: [...(screenCase.scenarioIds ?? [])],
|
|
228
|
+
route,
|
|
229
|
+
viewport: {
|
|
230
|
+
width: screenCase.width ?? DEFAULT_VIEWPORT.width,
|
|
231
|
+
height: screenCase.height ?? DEFAULT_VIEWPORT.height,
|
|
232
|
+
},
|
|
233
|
+
domStructureHash: hashStoryboardCapture(
|
|
234
|
+
normalizeStoryboardDomStructure(domTree),
|
|
235
|
+
),
|
|
236
|
+
screenshotHash: hashStoryboardCapture(screenshot),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function normalizeText(value) {
|
|
241
|
+
return String(value ?? '')
|
|
242
|
+
.replace(/\s+/g, ' ')
|
|
243
|
+
.trim();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Returns every user-facing label that can identify an interactive element.
|
|
248
|
+
*
|
|
249
|
+
* This function is self-contained because Playwright serializes it into the page.
|
|
250
|
+
*/
|
|
251
|
+
export function storyboardCaptureCandidateTexts(element) {
|
|
252
|
+
const valueControl =
|
|
253
|
+
element instanceof HTMLInputElement ||
|
|
254
|
+
element instanceof HTMLTextAreaElement ||
|
|
255
|
+
element instanceof HTMLSelectElement;
|
|
256
|
+
return [
|
|
257
|
+
...new Set(
|
|
258
|
+
[
|
|
259
|
+
element.getAttribute('aria-label') ?? '',
|
|
260
|
+
element.getAttribute('title') ?? '',
|
|
261
|
+
valueControl ? element.value : '',
|
|
262
|
+
element.textContent ?? '',
|
|
263
|
+
]
|
|
264
|
+
.map((value) => String(value).trim())
|
|
265
|
+
.filter(Boolean),
|
|
266
|
+
),
|
|
267
|
+
];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function candidateLocator(page, item) {
|
|
271
|
+
const selector =
|
|
272
|
+
item.selector ??
|
|
273
|
+
'button,a,input,textarea,select,[role="button"],[role="tab"],[role="menuitem"],[role="option"]';
|
|
274
|
+
const candidates = page.locator(selector);
|
|
275
|
+
if (!item.text) return candidates.nth(item.index ?? 0);
|
|
276
|
+
const expected = normalizeText(item.text);
|
|
277
|
+
const count = await candidates.count();
|
|
278
|
+
const matches = [];
|
|
279
|
+
for (let index = 0; index < count; index += 1) {
|
|
280
|
+
const candidate = candidates.nth(index);
|
|
281
|
+
const actual = await candidate.evaluate(storyboardCaptureCandidateTexts);
|
|
282
|
+
const normalized = actual.map(normalizeText);
|
|
283
|
+
if (
|
|
284
|
+
normalized.some((value) =>
|
|
285
|
+
item.exact ? value === expected : value.includes(expected),
|
|
286
|
+
)
|
|
287
|
+
) {
|
|
288
|
+
matches.push(candidate);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return (
|
|
292
|
+
matches[item.index ?? 0] ?? page.locator('__pygmalion_missing_target__')
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function targetExists(locator, timeoutMs, state = 'attached') {
|
|
297
|
+
try {
|
|
298
|
+
await locator.waitFor({ state, timeout: timeoutMs });
|
|
299
|
+
return true;
|
|
300
|
+
} catch {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function waitForCandidateLocator(page, item, timeoutMs) {
|
|
306
|
+
const startedAt = Date.now();
|
|
307
|
+
do {
|
|
308
|
+
const target = await candidateLocator(page, item);
|
|
309
|
+
if ((await target.count()) > 0) return target;
|
|
310
|
+
if (Date.now() - startedAt >= timeoutMs) return target;
|
|
311
|
+
await page.waitForTimeout(100);
|
|
312
|
+
} while (true);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export async function runStoryboardInteraction(page, interaction) {
|
|
316
|
+
const settleMs =
|
|
317
|
+
interaction.settleMs ?? (interaction.action === 'storage' ? 50 : 600);
|
|
318
|
+
if (interaction.action === 'wait') {
|
|
319
|
+
if (interaction.selector || interaction.text) {
|
|
320
|
+
const timeoutMs = interaction.timeoutMs ?? 5_000;
|
|
321
|
+
const waitState = interaction.waitState ?? 'visible';
|
|
322
|
+
if (!['attached', 'visible'].includes(waitState)) {
|
|
323
|
+
throw new Error(
|
|
324
|
+
`"${interaction.label}" waitState must be attached or visible.`,
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const target = await waitForCandidateLocator(
|
|
328
|
+
page,
|
|
329
|
+
interaction,
|
|
330
|
+
timeoutMs,
|
|
331
|
+
);
|
|
332
|
+
if (!(await targetExists(target, timeoutMs, waitState))) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`"${interaction.label}" did not reach the expected screen state.`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
await page.waitForTimeout(
|
|
339
|
+
interaction.settleMs ?? Number(interaction.value ?? 500),
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (interaction.action === 'storage') {
|
|
345
|
+
await page.evaluate(
|
|
346
|
+
({ storageType, key, value }) => {
|
|
347
|
+
const target =
|
|
348
|
+
storageType === 'session'
|
|
349
|
+
? window.sessionStorage
|
|
350
|
+
: window.localStorage;
|
|
351
|
+
if (!key) throw new Error('Storage key is missing.');
|
|
352
|
+
if (value == null) target.removeItem(key);
|
|
353
|
+
else target.setItem(key, value);
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
storageType: interaction.storage,
|
|
357
|
+
key: interaction.key,
|
|
358
|
+
value: interaction.value,
|
|
359
|
+
},
|
|
360
|
+
);
|
|
361
|
+
await page.waitForTimeout(settleMs);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const timeoutMs = interaction.timeoutMs ?? 5_000;
|
|
366
|
+
const target = await waitForCandidateLocator(page, interaction, timeoutMs);
|
|
367
|
+
if (!(await targetExists(target, Math.min(timeoutMs, 500)))) {
|
|
368
|
+
throw new Error(`"${interaction.label}" target was not found.`);
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
if (interaction.action === 'click') {
|
|
372
|
+
await target.click({ timeout: timeoutMs });
|
|
373
|
+
} else if (interaction.action === 'focus') {
|
|
374
|
+
await target.focus({ timeout: timeoutMs });
|
|
375
|
+
} else if (interaction.action === 'fill') {
|
|
376
|
+
await target.fill(interaction.value ?? '', { timeout: timeoutMs });
|
|
377
|
+
} else if (interaction.action === 'check') {
|
|
378
|
+
await target.check({ timeout: timeoutMs });
|
|
379
|
+
} else if (interaction.action === 'press') {
|
|
380
|
+
await target.press(interaction.key ?? interaction.value ?? 'Enter', {
|
|
381
|
+
timeout: timeoutMs,
|
|
382
|
+
});
|
|
383
|
+
} else {
|
|
384
|
+
throw new Error(`Unsupported interaction action: ${interaction.action}`);
|
|
385
|
+
}
|
|
386
|
+
} catch (error) {
|
|
387
|
+
throw new Error(
|
|
388
|
+
`"${interaction.label}" interaction failed: ${messageOf(error)}`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
await page.waitForTimeout(settleMs);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function assertionFailure(page, assertion) {
|
|
395
|
+
let target;
|
|
396
|
+
try {
|
|
397
|
+
target = page.locator(assertion.selector).nth(assertion.index ?? 0);
|
|
398
|
+
} catch {
|
|
399
|
+
return 'The selector is invalid.';
|
|
400
|
+
}
|
|
401
|
+
const count = await page
|
|
402
|
+
.locator(assertion.selector)
|
|
403
|
+
.count()
|
|
404
|
+
.catch(() => 0);
|
|
405
|
+
const exists = count > (assertion.index ?? 0);
|
|
406
|
+
const expectedExists = assertion.exists !== false;
|
|
407
|
+
if (exists !== expectedExists) {
|
|
408
|
+
return `Existence mismatch (${exists} !== ${expectedExists}).`;
|
|
409
|
+
}
|
|
410
|
+
if (!exists) return null;
|
|
411
|
+
if (assertion.text != null) {
|
|
412
|
+
const actual = normalizeText(
|
|
413
|
+
await target.evaluate((element) =>
|
|
414
|
+
'value' in element
|
|
415
|
+
? String(element.value ?? '')
|
|
416
|
+
: (element.textContent ?? ''),
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
const expected = normalizeText(assertion.text);
|
|
420
|
+
if (assertion.exact ? actual !== expected : !actual.includes(expected)) {
|
|
421
|
+
return `Text mismatch ("${actual}" / "${expected}").`;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (assertion.attribute) {
|
|
425
|
+
const actual = await target.getAttribute(assertion.attribute.name);
|
|
426
|
+
const present = actual != null;
|
|
427
|
+
const expectedPresent = assertion.attribute.present !== false;
|
|
428
|
+
const matchesValue =
|
|
429
|
+
assertion.attribute.value == null ||
|
|
430
|
+
(assertion.attribute.match === 'contains'
|
|
431
|
+
? (actual ?? '').includes(assertion.attribute.value)
|
|
432
|
+
: actual === assertion.attribute.value);
|
|
433
|
+
if (present !== expectedPresent || (expectedPresent && !matchesValue)) {
|
|
434
|
+
return `Attribute mismatch (${assertion.attribute.name}).`;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (assertion.visibility) {
|
|
438
|
+
const visible = await target.evaluate((element) => {
|
|
439
|
+
const style = getComputedStyle(element);
|
|
440
|
+
const rect = element.getBoundingClientRect();
|
|
441
|
+
return (
|
|
442
|
+
style.display !== 'none' &&
|
|
443
|
+
style.visibility !== 'hidden' &&
|
|
444
|
+
Number(style.opacity) !== 0 &&
|
|
445
|
+
rect.width > 0 &&
|
|
446
|
+
rect.height > 0
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
if ((assertion.visibility === 'visible') !== visible) {
|
|
450
|
+
return 'Visibility mismatch.';
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export async function runStoryboardAssertion(page, assertion) {
|
|
457
|
+
const timeoutMs = assertion.timeoutMs ?? 2_000;
|
|
458
|
+
const startedAt = Date.now();
|
|
459
|
+
let failure = null;
|
|
460
|
+
do {
|
|
461
|
+
failure = await assertionFailure(page, assertion);
|
|
462
|
+
if (!failure || Date.now() - startedAt >= timeoutMs) break;
|
|
463
|
+
await page.waitForTimeout(100);
|
|
464
|
+
} while (true);
|
|
465
|
+
if (failure) {
|
|
466
|
+
throw new StoryboardCaptureStageError(
|
|
467
|
+
'assertion',
|
|
468
|
+
new Error(`"${assertion.label}" assertion failed: ${failure}`),
|
|
469
|
+
{
|
|
470
|
+
label: assertion.label,
|
|
471
|
+
selector: assertion.selector,
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Finds a visible modal that is not explicitly authorized by a positive assertion.
|
|
479
|
+
*
|
|
480
|
+
* This function is self-contained because Playwright serializes it into the page.
|
|
481
|
+
*/
|
|
482
|
+
export function findUnassertedStoryboardModal(assertions = []) {
|
|
483
|
+
const modalSelector =
|
|
484
|
+
'dialog[open],[role="dialog"],[role="alertdialog"],[aria-modal="true"]';
|
|
485
|
+
const visibleModals = [...document.querySelectorAll(modalSelector)].filter(
|
|
486
|
+
(element) => {
|
|
487
|
+
const style = getComputedStyle(element);
|
|
488
|
+
const rect = element.getBoundingClientRect();
|
|
489
|
+
const intersectsViewport =
|
|
490
|
+
!Number.isFinite(rect.top) ||
|
|
491
|
+
!Number.isFinite(rect.left) ||
|
|
492
|
+
(rect.bottom > 0 &&
|
|
493
|
+
rect.right > 0 &&
|
|
494
|
+
rect.top < window.innerHeight &&
|
|
495
|
+
rect.left < window.innerWidth);
|
|
496
|
+
return (
|
|
497
|
+
!element.hasAttribute('hidden') &&
|
|
498
|
+
element.getAttribute('aria-hidden') !== 'true' &&
|
|
499
|
+
style.display !== 'none' &&
|
|
500
|
+
style.visibility !== 'hidden' &&
|
|
501
|
+
Number.parseFloat(style.opacity || '1') !== 0 &&
|
|
502
|
+
rect.width > 0 &&
|
|
503
|
+
rect.height > 0 &&
|
|
504
|
+
intersectsViewport
|
|
505
|
+
);
|
|
506
|
+
},
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
for (const modal of visibleModals) {
|
|
510
|
+
const asserted = assertions.some((assertion) => {
|
|
511
|
+
if (
|
|
512
|
+
assertion?.optional ||
|
|
513
|
+
assertion?.exists === false ||
|
|
514
|
+
typeof assertion?.selector !== 'string'
|
|
515
|
+
) {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const matches = document.querySelectorAll(assertion.selector);
|
|
520
|
+
const index = Number.isInteger(assertion.index) ? assertion.index : 0;
|
|
521
|
+
const target = matches[index];
|
|
522
|
+
return Boolean(target && (target === modal || modal.contains(target)));
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
if (asserted) continue;
|
|
528
|
+
|
|
529
|
+
const labelledBy = modal.getAttribute('aria-labelledby');
|
|
530
|
+
const labelledText = labelledBy
|
|
531
|
+
? document.getElementById(labelledBy)?.textContent
|
|
532
|
+
: '';
|
|
533
|
+
const label = (
|
|
534
|
+
modal.getAttribute('aria-label') ||
|
|
535
|
+
labelledText ||
|
|
536
|
+
modal.textContent ||
|
|
537
|
+
''
|
|
538
|
+
)
|
|
539
|
+
.replace(/\s+/g, ' ')
|
|
540
|
+
.trim()
|
|
541
|
+
.slice(0, 120);
|
|
542
|
+
return {
|
|
543
|
+
role: modal.getAttribute('role') || modal.tagName.toLowerCase(),
|
|
544
|
+
label,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
export async function assertFinalStoryboardState(page, assertions) {
|
|
551
|
+
for (const assertion of assertions) {
|
|
552
|
+
await runStoryboardAssertion(page, assertion);
|
|
553
|
+
}
|
|
554
|
+
const unassertedModal = await page.evaluate(
|
|
555
|
+
findUnassertedStoryboardModal,
|
|
556
|
+
assertions,
|
|
557
|
+
);
|
|
558
|
+
if (!unassertedModal) return;
|
|
559
|
+
throw new StoryboardCaptureStageError(
|
|
560
|
+
'assertion',
|
|
561
|
+
new Error(
|
|
562
|
+
`A visible ${unassertedModal.role} has no final assertion` +
|
|
563
|
+
`${unassertedModal.label ? `: ${unassertedModal.label}` : ''}`,
|
|
564
|
+
),
|
|
565
|
+
{
|
|
566
|
+
label: unassertedModal.label,
|
|
567
|
+
selector:
|
|
568
|
+
'dialog[open],[role="dialog"],[role="alertdialog"],[aria-modal="true"]',
|
|
569
|
+
},
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function installStoryboardPreset(context, page, preset) {
|
|
574
|
+
if (!preset) return;
|
|
575
|
+
await context.addInitScript((value) => {
|
|
576
|
+
window.__PYGMALION_SCREEN_PRESET__ = value;
|
|
577
|
+
const local = value.localStorage ?? value.storage?.local;
|
|
578
|
+
const session = value.sessionStorage ?? value.storage?.session;
|
|
579
|
+
try {
|
|
580
|
+
for (const [key, item] of Object.entries(local ?? {})) {
|
|
581
|
+
if (item == null) window.localStorage.removeItem(key);
|
|
582
|
+
else window.localStorage.setItem(key, String(item));
|
|
583
|
+
}
|
|
584
|
+
for (const [key, item] of Object.entries(session ?? {})) {
|
|
585
|
+
if (item == null) window.sessionStorage.removeItem(key);
|
|
586
|
+
else window.sessionStorage.setItem(key, String(item));
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
// The script runs again after an opaque initial page reaches the app origin.
|
|
590
|
+
}
|
|
591
|
+
}, preset);
|
|
592
|
+
const media = preset.media ?? preset;
|
|
593
|
+
if (media.colorScheme || media.reducedMotion) {
|
|
594
|
+
await page.emulateMedia({
|
|
595
|
+
colorScheme: media.colorScheme,
|
|
596
|
+
reducedMotion: media.reducedMotion,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function executeStoryboardPreset(page, screenCase, route) {
|
|
602
|
+
if (!screenCase.preset) return;
|
|
603
|
+
const request = {
|
|
604
|
+
pageId: screenCase.id,
|
|
605
|
+
route,
|
|
606
|
+
preset: screenCase.preset,
|
|
607
|
+
};
|
|
608
|
+
await page.evaluate(async (payload) => {
|
|
609
|
+
const runtime = window;
|
|
610
|
+
runtime.sessionStorage.setItem(
|
|
611
|
+
'pygmalion:screen-capture-preset',
|
|
612
|
+
JSON.stringify(payload.preset),
|
|
613
|
+
);
|
|
614
|
+
await runtime.__PYGMALION_APPLY_SCREEN_PRESET__?.(payload);
|
|
615
|
+
runtime.dispatchEvent(
|
|
616
|
+
new CustomEvent('pygmalion:screen-capture-preset', {
|
|
617
|
+
detail: payload,
|
|
618
|
+
}),
|
|
619
|
+
);
|
|
620
|
+
}, request);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function collectStoryboardDomTree() {
|
|
624
|
+
const visit = (node) => {
|
|
625
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
626
|
+
return node.textContent?.trim() ? { type: 'text' } : null;
|
|
627
|
+
}
|
|
628
|
+
if (!(node instanceof Element)) return null;
|
|
629
|
+
if (['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(node.tagName)) {
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
return {
|
|
633
|
+
type: 'element',
|
|
634
|
+
tag: node.tagName.toLowerCase(),
|
|
635
|
+
attributes: [...node.attributes].map((attribute) => [
|
|
636
|
+
attribute.name,
|
|
637
|
+
attribute.value,
|
|
638
|
+
]),
|
|
639
|
+
children: [...node.childNodes].map(visit).filter(Boolean),
|
|
640
|
+
};
|
|
641
|
+
};
|
|
642
|
+
return visit(document.body);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Returns a compact stability signature for the rendered document.
|
|
647
|
+
*
|
|
648
|
+
* The mutation revision observes the entire document element, which includes
|
|
649
|
+
* React portals mounted beside the application root. Layout and visible
|
|
650
|
+
* overlay details cover changes that can occur without changing DOM length.
|
|
651
|
+
* This function is self-contained because Playwright serializes it into the
|
|
652
|
+
* page.
|
|
653
|
+
*/
|
|
654
|
+
export function storyboardDocumentStabilitySignature() {
|
|
655
|
+
const runtime = window;
|
|
656
|
+
const trackerKey = '__PYGMALION_STORYBOARD_STABILITY__';
|
|
657
|
+
let tracker = runtime[trackerKey];
|
|
658
|
+
if (!tracker || tracker.document !== document) {
|
|
659
|
+
tracker?.observer?.disconnect?.();
|
|
660
|
+
tracker = {
|
|
661
|
+
document,
|
|
662
|
+
observer: null,
|
|
663
|
+
revision: 0,
|
|
664
|
+
};
|
|
665
|
+
if (
|
|
666
|
+
document.documentElement &&
|
|
667
|
+
typeof runtime.MutationObserver === 'function'
|
|
668
|
+
) {
|
|
669
|
+
tracker.observer = new runtime.MutationObserver(() => {
|
|
670
|
+
tracker.revision += 1;
|
|
671
|
+
});
|
|
672
|
+
tracker.observer.observe(document.documentElement, {
|
|
673
|
+
attributes: true,
|
|
674
|
+
characterData: true,
|
|
675
|
+
childList: true,
|
|
676
|
+
subtree: true,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
runtime[trackerKey] = tracker;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const root = document.documentElement;
|
|
683
|
+
const body = document.body;
|
|
684
|
+
const modalSelector =
|
|
685
|
+
'dialog[open],[role="dialog"],[role="alertdialog"],[aria-modal="true"]';
|
|
686
|
+
const overlays = [...document.querySelectorAll(modalSelector)]
|
|
687
|
+
.filter((element) => {
|
|
688
|
+
const style = getComputedStyle(element);
|
|
689
|
+
const rect = element.getBoundingClientRect();
|
|
690
|
+
return (
|
|
691
|
+
!element.hasAttribute('hidden') &&
|
|
692
|
+
element.getAttribute('aria-hidden') !== 'true' &&
|
|
693
|
+
style.display !== 'none' &&
|
|
694
|
+
style.visibility !== 'hidden' &&
|
|
695
|
+
Number.parseFloat(style.opacity || '1') !== 0 &&
|
|
696
|
+
rect.width > 0 &&
|
|
697
|
+
rect.height > 0
|
|
698
|
+
);
|
|
699
|
+
})
|
|
700
|
+
.map((element) => {
|
|
701
|
+
const rect = element.getBoundingClientRect();
|
|
702
|
+
return [
|
|
703
|
+
element.tagName,
|
|
704
|
+
element.getAttribute('role') ?? '',
|
|
705
|
+
element.getAttribute('aria-modal') ?? '',
|
|
706
|
+
Math.round(rect.left),
|
|
707
|
+
Math.round(rect.top),
|
|
708
|
+
Math.round(rect.width),
|
|
709
|
+
Math.round(rect.height),
|
|
710
|
+
(element.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 160),
|
|
711
|
+
];
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
return JSON.stringify({
|
|
715
|
+
readyState: document.readyState,
|
|
716
|
+
revision: tracker.revision,
|
|
717
|
+
elementCount: body?.querySelectorAll('*').length ?? 0,
|
|
718
|
+
scrollWidth: Math.max(root?.scrollWidth ?? 0, body?.scrollWidth ?? 0),
|
|
719
|
+
scrollHeight: Math.max(root?.scrollHeight ?? 0, body?.scrollHeight ?? 0),
|
|
720
|
+
overlays,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export async function waitForStableStoryboardDocument(
|
|
725
|
+
page,
|
|
726
|
+
{
|
|
727
|
+
attempts = 20,
|
|
728
|
+
requiredStableSamples = 3,
|
|
729
|
+
intervalMs = 100,
|
|
730
|
+
minimumWaitMs = 400,
|
|
731
|
+
} = {},
|
|
732
|
+
) {
|
|
733
|
+
await page.evaluate(async () => {
|
|
734
|
+
await document.fonts?.ready;
|
|
735
|
+
});
|
|
736
|
+
let previous = '';
|
|
737
|
+
let stable = 0;
|
|
738
|
+
let elapsedMs = 0;
|
|
739
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
740
|
+
const current = await page.evaluate(storyboardDocumentStabilitySignature);
|
|
741
|
+
if (current === previous) stable += 1;
|
|
742
|
+
else stable = 0;
|
|
743
|
+
previous = current;
|
|
744
|
+
if (stable >= requiredStableSamples && elapsedMs >= minimumWaitMs) {
|
|
745
|
+
return true;
|
|
746
|
+
}
|
|
747
|
+
await page.waitForTimeout(intervalMs);
|
|
748
|
+
elapsedMs += intervalMs;
|
|
749
|
+
}
|
|
750
|
+
return stable >= requiredStableSamples && elapsedMs >= minimumWaitMs;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Serializes the current document into an inert, standalone DOM preview.
|
|
755
|
+
*
|
|
756
|
+
* This function is self-contained because Playwright serializes it into the page.
|
|
757
|
+
*/
|
|
758
|
+
export function serializeStoryboardPreviewDocument(
|
|
759
|
+
baseHref = '__PYGMALION_PREVIEW_BASE__',
|
|
760
|
+
) {
|
|
761
|
+
const clone = document.documentElement.cloneNode(true);
|
|
762
|
+
if (!(clone instanceof HTMLElement)) return null;
|
|
763
|
+
|
|
764
|
+
const sourceInputs = [...document.querySelectorAll('input')];
|
|
765
|
+
const cloneInputs = [...clone.querySelectorAll('input')];
|
|
766
|
+
sourceInputs.forEach((source, index) => {
|
|
767
|
+
const target = cloneInputs[index];
|
|
768
|
+
if (!target) return;
|
|
769
|
+
target.setAttribute('value', source.value);
|
|
770
|
+
if (source.checked) target.setAttribute('checked', '');
|
|
771
|
+
else target.removeAttribute('checked');
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const sourceTextareas = [...document.querySelectorAll('textarea')];
|
|
775
|
+
const cloneTextareas = [...clone.querySelectorAll('textarea')];
|
|
776
|
+
sourceTextareas.forEach((source, index) => {
|
|
777
|
+
const target = cloneTextareas[index];
|
|
778
|
+
if (target) target.textContent = source.value;
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const sourceSelects = [...document.querySelectorAll('select')];
|
|
782
|
+
const cloneSelects = [...clone.querySelectorAll('select')];
|
|
783
|
+
sourceSelects.forEach((source, index) => {
|
|
784
|
+
const target = cloneSelects[index];
|
|
785
|
+
if (!target) return;
|
|
786
|
+
[...target.options].forEach((option, optionIndex) => {
|
|
787
|
+
if (source.options[optionIndex]?.selected) {
|
|
788
|
+
option.setAttribute('selected', '');
|
|
789
|
+
} else {
|
|
790
|
+
option.removeAttribute('selected');
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const sourceCanvases = [...document.querySelectorAll('canvas')];
|
|
796
|
+
const cloneCanvases = [...clone.querySelectorAll('canvas')];
|
|
797
|
+
sourceCanvases.forEach((source, index) => {
|
|
798
|
+
const target = cloneCanvases[index];
|
|
799
|
+
if (!target) return;
|
|
800
|
+
const image = document.createElement('img');
|
|
801
|
+
for (const attribute of [...target.attributes]) {
|
|
802
|
+
image.setAttribute(attribute.name, attribute.value);
|
|
803
|
+
}
|
|
804
|
+
image.width = source.width;
|
|
805
|
+
image.height = source.height;
|
|
806
|
+
image.alt ||= 'canvas snapshot';
|
|
807
|
+
try {
|
|
808
|
+
image.src = source.toDataURL();
|
|
809
|
+
} catch {
|
|
810
|
+
image.src =
|
|
811
|
+
'data:image/svg+xml;charset=utf-8,' +
|
|
812
|
+
encodeURIComponent(
|
|
813
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${source.width}" height="${source.height}"/>`,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
target.replaceWith(image);
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
clone
|
|
820
|
+
.querySelectorAll(
|
|
821
|
+
'script,noscript,iframe,object,embed,link[rel="modulepreload"],link[rel="preload"][as="script"]',
|
|
822
|
+
)
|
|
823
|
+
.forEach((element) => element.remove());
|
|
824
|
+
for (const element of [clone, ...clone.querySelectorAll('*')]) {
|
|
825
|
+
for (const attribute of [...element.attributes]) {
|
|
826
|
+
const name = attribute.name.toLowerCase();
|
|
827
|
+
if (name.startsWith('on')) element.removeAttribute(attribute.name);
|
|
828
|
+
if (
|
|
829
|
+
(name === 'href' ||
|
|
830
|
+
name === 'src' ||
|
|
831
|
+
name === 'action' ||
|
|
832
|
+
name === 'formaction') &&
|
|
833
|
+
/^\s*javascript\s*:/i.test(attribute.value)
|
|
834
|
+
) {
|
|
835
|
+
element.removeAttribute(attribute.name);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let head = clone.querySelector('head');
|
|
841
|
+
if (!head) {
|
|
842
|
+
head = document.createElement('head');
|
|
843
|
+
clone.prepend(head);
|
|
844
|
+
}
|
|
845
|
+
head.querySelectorAll('base').forEach((element) => element.remove());
|
|
846
|
+
const base = document.createElement('base');
|
|
847
|
+
base.setAttribute('href', baseHref);
|
|
848
|
+
head.prepend(base);
|
|
849
|
+
|
|
850
|
+
const csp = document.createElement('meta');
|
|
851
|
+
csp.setAttribute('http-equiv', 'Content-Security-Policy');
|
|
852
|
+
csp.setAttribute('content', "script-src 'none'; object-src 'none'");
|
|
853
|
+
head.prepend(csp);
|
|
854
|
+
|
|
855
|
+
const frozenStyle = document.createElement('style');
|
|
856
|
+
frozenStyle.setAttribute('data-pygmalion-preview', 'frozen');
|
|
857
|
+
frozenStyle.textContent =
|
|
858
|
+
'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}html,body{pointer-events:none!important}';
|
|
859
|
+
head.append(frozenStyle);
|
|
860
|
+
|
|
861
|
+
return `<!doctype html>${clone.outerHTML}`;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function captureDiagnostic(error, viewport) {
|
|
865
|
+
const stage =
|
|
866
|
+
error instanceof StoryboardCaptureStageError ? error.stage : 'unknown';
|
|
867
|
+
const details =
|
|
868
|
+
error instanceof StoryboardCaptureStageError ? error.details : {};
|
|
869
|
+
const selector = boundedDiagnosticText(details.selector, 4_096);
|
|
870
|
+
const label = boundedDiagnosticText(details.label, 1_024);
|
|
871
|
+
const message =
|
|
872
|
+
boundedDiagnosticText(messageOf(error), 4_096) ?? 'Capture failed.';
|
|
873
|
+
return {
|
|
874
|
+
stage,
|
|
875
|
+
code: QA_FAILURE_STAGES.has(stage)
|
|
876
|
+
? `${stage}-failed`
|
|
877
|
+
: 'capture-stage-failed',
|
|
878
|
+
...(selector ? { selector } : {}),
|
|
879
|
+
...(label ? { label } : {}),
|
|
880
|
+
message,
|
|
881
|
+
viewport,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
async function collectStableEvidence(
|
|
886
|
+
page,
|
|
887
|
+
viewport,
|
|
888
|
+
{
|
|
889
|
+
includeDomTree,
|
|
890
|
+
includePreviewSnapshot,
|
|
891
|
+
previewBaseToken,
|
|
892
|
+
screenshotOptions,
|
|
893
|
+
},
|
|
894
|
+
) {
|
|
895
|
+
const evidence = {};
|
|
896
|
+
const errors = [];
|
|
897
|
+
try {
|
|
898
|
+
await page.addStyleTag({ content: FROZEN_STYLE });
|
|
899
|
+
const stable = await waitForStableStoryboardDocument(page);
|
|
900
|
+
if (!stable) {
|
|
901
|
+
throw new Error(
|
|
902
|
+
'The rendered document did not reach a stable DOM and overlay state.',
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
} catch (error) {
|
|
906
|
+
errors.push(new StoryboardCaptureStageError('stabilize', error));
|
|
907
|
+
}
|
|
908
|
+
if (includeDomTree) {
|
|
909
|
+
try {
|
|
910
|
+
evidence.domTree = await page.evaluate(collectStoryboardDomTree);
|
|
911
|
+
} catch (error) {
|
|
912
|
+
errors.push(new StoryboardCaptureStageError('serialize', error));
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (includePreviewSnapshot) {
|
|
916
|
+
try {
|
|
917
|
+
const snapshot = await page.evaluate(
|
|
918
|
+
serializeStoryboardPreviewDocument,
|
|
919
|
+
previewBaseToken,
|
|
920
|
+
);
|
|
921
|
+
if (
|
|
922
|
+
typeof snapshot !== 'string' ||
|
|
923
|
+
!snapshot.includes(previewBaseToken)
|
|
924
|
+
) {
|
|
925
|
+
throw new Error('The inert DOM snapshot is invalid.');
|
|
926
|
+
}
|
|
927
|
+
evidence.snapshot = snapshot;
|
|
928
|
+
} catch (error) {
|
|
929
|
+
errors.push(new StoryboardCaptureStageError('serialize', error));
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
try {
|
|
933
|
+
evidence.screenshot = {
|
|
934
|
+
mediaType: 'image/png',
|
|
935
|
+
width: viewport.width,
|
|
936
|
+
height: viewport.height,
|
|
937
|
+
bytes: await page.screenshot({
|
|
938
|
+
type: 'png',
|
|
939
|
+
fullPage: false,
|
|
940
|
+
animations: 'disabled',
|
|
941
|
+
caret: 'hide',
|
|
942
|
+
...screenshotOptions,
|
|
943
|
+
}),
|
|
944
|
+
};
|
|
945
|
+
} catch (error) {
|
|
946
|
+
errors.push(new StoryboardCaptureStageError('screenshot', error));
|
|
947
|
+
}
|
|
948
|
+
return { evidence, errors };
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function captureResult({
|
|
952
|
+
screenCase,
|
|
953
|
+
route,
|
|
954
|
+
viewport,
|
|
955
|
+
status,
|
|
956
|
+
evidence,
|
|
957
|
+
diagnostics,
|
|
958
|
+
}) {
|
|
959
|
+
return {
|
|
960
|
+
id: screenCase.id,
|
|
961
|
+
route,
|
|
962
|
+
status,
|
|
963
|
+
viewport,
|
|
964
|
+
...(evidence.snapshot == null ? {} : { snapshot: evidence.snapshot }),
|
|
965
|
+
...(evidence.screenshot == null ? {} : { screenshot: evidence.screenshot }),
|
|
966
|
+
...(evidence.domTree == null ? {} : { domTree: evidence.domTree }),
|
|
967
|
+
diagnostics,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* Captures one storyboard case with a Playwright-compatible browser.
|
|
973
|
+
*
|
|
974
|
+
* Pygmalion owns the deterministic browser lifecycle and QA status model. The
|
|
975
|
+
* host application owns only domain setup through hooks.
|
|
976
|
+
*/
|
|
977
|
+
export async function captureStoryboardCase({
|
|
978
|
+
browser,
|
|
979
|
+
baseUrl,
|
|
980
|
+
screenCase,
|
|
981
|
+
signal,
|
|
982
|
+
hooks = {},
|
|
983
|
+
includeDomTree = true,
|
|
984
|
+
includePreviewSnapshot = true,
|
|
985
|
+
previewBaseToken = '__PYGMALION_PREVIEW_BASE__',
|
|
986
|
+
contextOptions = {},
|
|
987
|
+
navigationOptions = {},
|
|
988
|
+
screenshotOptions = {},
|
|
989
|
+
} = {}) {
|
|
990
|
+
if (!browser || typeof browser.newContext !== 'function') {
|
|
991
|
+
throw new TypeError(
|
|
992
|
+
'captureStoryboardCase requires a Playwright-compatible browser.',
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
if (!screenCase || typeof screenCase !== 'object') {
|
|
996
|
+
throw new TypeError('captureStoryboardCase requires a screen case.');
|
|
997
|
+
}
|
|
998
|
+
const viewport = captureViewport(screenCase);
|
|
999
|
+
const route = resolveStoryboardCaptureRoute(screenCase);
|
|
1000
|
+
if (route == null || route === '') {
|
|
1001
|
+
const error = new StoryboardCaptureStageError(
|
|
1002
|
+
'recipe',
|
|
1003
|
+
'The screen case has no route, coverage route, or path.',
|
|
1004
|
+
);
|
|
1005
|
+
return captureResult({
|
|
1006
|
+
screenCase,
|
|
1007
|
+
route: null,
|
|
1008
|
+
viewport,
|
|
1009
|
+
status: STORYBOARD_CAPTURE_STATUSES.captureError,
|
|
1010
|
+
evidence: {},
|
|
1011
|
+
diagnostics: [captureDiagnostic(error, viewport)],
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
let context = null;
|
|
1016
|
+
let page = null;
|
|
1017
|
+
let primaryError = null;
|
|
1018
|
+
let evidence = {};
|
|
1019
|
+
const evidenceErrors = [];
|
|
1020
|
+
try {
|
|
1021
|
+
throwIfAborted(signal);
|
|
1022
|
+
context = await atCaptureStage('setup', () =>
|
|
1023
|
+
browser.newContext({
|
|
1024
|
+
viewport,
|
|
1025
|
+
colorScheme: 'light',
|
|
1026
|
+
reducedMotion: 'reduce',
|
|
1027
|
+
locale: 'en-US',
|
|
1028
|
+
timezoneId: 'UTC',
|
|
1029
|
+
...contextOptions,
|
|
1030
|
+
viewport,
|
|
1031
|
+
}),
|
|
1032
|
+
);
|
|
1033
|
+
await atCaptureStage('setup', () =>
|
|
1034
|
+
context.addInitScript(() => {
|
|
1035
|
+
let seed = 0x5f3759df;
|
|
1036
|
+
Math.random = () => {
|
|
1037
|
+
seed = (seed * 1664525 + 1013904223) >>> 0;
|
|
1038
|
+
return seed / 0x100000000;
|
|
1039
|
+
};
|
|
1040
|
+
}),
|
|
1041
|
+
);
|
|
1042
|
+
page = await atCaptureStage('setup', () => context.newPage());
|
|
1043
|
+
|
|
1044
|
+
await atCaptureStage('preset', async () => {
|
|
1045
|
+
throwIfAborted(signal);
|
|
1046
|
+
const fixedTime = resolveStoryboardFixedTime(screenCase.preset);
|
|
1047
|
+
if (fixedTime) await page.clock.setFixedTime(fixedTime);
|
|
1048
|
+
await hooks.beforePreset?.({ context, page, screenCase, route, signal });
|
|
1049
|
+
await installStoryboardPreset(context, page, screenCase.preset);
|
|
1050
|
+
});
|
|
1051
|
+
await atCaptureStage('navigation', () =>
|
|
1052
|
+
page.goto(
|
|
1053
|
+
resolveStoryboardCaptureUrl(baseUrl, route, screenCase.environment),
|
|
1054
|
+
{
|
|
1055
|
+
waitUntil: 'domcontentloaded',
|
|
1056
|
+
timeout: 30_000,
|
|
1057
|
+
...navigationOptions,
|
|
1058
|
+
},
|
|
1059
|
+
),
|
|
1060
|
+
);
|
|
1061
|
+
await atCaptureStage('preset', async () => {
|
|
1062
|
+
await executeStoryboardPreset(page, screenCase, route);
|
|
1063
|
+
await hooks.afterPreset?.({
|
|
1064
|
+
context,
|
|
1065
|
+
page,
|
|
1066
|
+
screenCase,
|
|
1067
|
+
route,
|
|
1068
|
+
signal,
|
|
1069
|
+
});
|
|
1070
|
+
});
|
|
1071
|
+
for (const interaction of screenCase.interactions ?? []) {
|
|
1072
|
+
throwIfAborted(signal);
|
|
1073
|
+
await atCaptureStage(
|
|
1074
|
+
'interaction',
|
|
1075
|
+
() => runStoryboardInteraction(page, interaction),
|
|
1076
|
+
{
|
|
1077
|
+
label: interaction.label,
|
|
1078
|
+
selector: interaction.selector,
|
|
1079
|
+
},
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
await atCaptureStage('stabilize', async () => {
|
|
1083
|
+
await page.addStyleTag({ content: FROZEN_STYLE });
|
|
1084
|
+
const stable = await waitForStableStoryboardDocument(page);
|
|
1085
|
+
if (!stable) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
'The rendered document did not reach a stable DOM and overlay state.',
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
});
|
|
1091
|
+
await atCaptureStage('assertion', async () => {
|
|
1092
|
+
await assertFinalStoryboardState(page, screenCase.assertions ?? []);
|
|
1093
|
+
});
|
|
1094
|
+
} catch (error) {
|
|
1095
|
+
primaryError =
|
|
1096
|
+
error instanceof StoryboardCaptureStageError
|
|
1097
|
+
? error
|
|
1098
|
+
: new StoryboardCaptureStageError('unknown', error);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (page) {
|
|
1102
|
+
const collected = await collectStableEvidence(page, viewport, {
|
|
1103
|
+
includeDomTree,
|
|
1104
|
+
includePreviewSnapshot,
|
|
1105
|
+
previewBaseToken,
|
|
1106
|
+
screenshotOptions,
|
|
1107
|
+
});
|
|
1108
|
+
evidence = collected.evidence;
|
|
1109
|
+
evidenceErrors.push(...collected.errors);
|
|
1110
|
+
if (!primaryError) {
|
|
1111
|
+
try {
|
|
1112
|
+
await atCaptureStage('assertion', () =>
|
|
1113
|
+
assertFinalStoryboardState(page, screenCase.assertions ?? []),
|
|
1114
|
+
);
|
|
1115
|
+
} catch (error) {
|
|
1116
|
+
primaryError = error;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
try {
|
|
1122
|
+
await context?.close();
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
evidenceErrors.push(new StoryboardCaptureStageError('teardown', error));
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (!primaryError && evidenceErrors.length === 0) {
|
|
1128
|
+
return captureResult({
|
|
1129
|
+
screenCase,
|
|
1130
|
+
route,
|
|
1131
|
+
viewport,
|
|
1132
|
+
status: STORYBOARD_CAPTURE_STATUSES.ready,
|
|
1133
|
+
evidence,
|
|
1134
|
+
diagnostics: [],
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const error =
|
|
1139
|
+
primaryError ??
|
|
1140
|
+
evidenceErrors[0] ??
|
|
1141
|
+
new StoryboardCaptureStageError('unknown', 'Capture failed.');
|
|
1142
|
+
const preservesStableOutput = Boolean(
|
|
1143
|
+
evidence.snapshot && evidence.screenshot,
|
|
1144
|
+
);
|
|
1145
|
+
const isQaFailure =
|
|
1146
|
+
error instanceof StoryboardCaptureStageError &&
|
|
1147
|
+
QA_FAILURE_STAGES.has(error.stage) &&
|
|
1148
|
+
preservesStableOutput;
|
|
1149
|
+
const diagnostics = [
|
|
1150
|
+
captureDiagnostic(error, viewport),
|
|
1151
|
+
...evidenceErrors
|
|
1152
|
+
.filter((item) => item !== error)
|
|
1153
|
+
.map((item) => captureDiagnostic(item, viewport)),
|
|
1154
|
+
];
|
|
1155
|
+
return captureResult({
|
|
1156
|
+
screenCase,
|
|
1157
|
+
route,
|
|
1158
|
+
viewport,
|
|
1159
|
+
status: isQaFailure
|
|
1160
|
+
? STORYBOARD_CAPTURE_STATUSES.qaFailure
|
|
1161
|
+
: STORYBOARD_CAPTURE_STATUSES.captureError,
|
|
1162
|
+
evidence,
|
|
1163
|
+
diagnostics,
|
|
1164
|
+
});
|
|
1165
|
+
}
|