@terra-symposium/standards-reporting 0.0.0-stage → 0.4.0

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,709 @@
1
+ /**
2
+ * packages/reporting/src/playwright.ts
3
+ * Playwright fixtures and reporter for reusable test evidence.
4
+ */
5
+ /*//////////////////////////////////////////////////
6
+ IMPORTS
7
+ //////////////////////////////////////////////////*/
8
+ // API
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+ import { baseVisualProjectName, projectViewportName, viewportSizeLabel } from './viewports.js';
13
+ import { diagnosticCount, normalizeVisualStatus } from './core.js';
14
+ import { gradeLoadMeasurement, loadGradeCounts, loadTestId, readLoadTestingConfig, timeLoadOperation, validateLoadBudgetOverride } from './load-testing.js';
15
+ import { test as base } from '@playwright/test';
16
+ import { writeScreenshotSlideshowFromDirectory, writeSlideshow } from './slideshow.js';
17
+ import { writeSpreadsheet } from './spreadsheet.js';
18
+ // DATA
19
+ import { VISUAL_VIEWPORTS } from './viewports.js';
20
+ /*//////////////////////////////////////////////////
21
+ FIXTURES
22
+ //////////////////////////////////////////////////*/
23
+ /** Playwright fixture that records browser console, page, and request failures. */
24
+ export const diagnosticsTest = base.extend({
25
+ page: async ({ page, context: ctx }, use, info) => {
26
+ const diagnostics = [];
27
+ const observe = (tgt) => {
28
+ tgt.on('console', (m) => {
29
+ if (!['error', 'warning'].includes(m.type()))
30
+ return;
31
+ diagnostics.push({ level: m.type(), message: m.text(), url: tgt.url() });
32
+ });
33
+ tgt.on('pageerror', (e) => diagnostics.push({ level: 'pageerror', message: e.message, url: tgt.url() }));
34
+ tgt.on('requestfailed', (r) => diagnostics.push({ level: 'requestfailed', message: `${r.method()} ${r.url()} ${r.failure()?.errorText || 'Request failed.'}`, url: r.url() }));
35
+ };
36
+ observe(page);
37
+ ctx.on('page', observe);
38
+ try {
39
+ await use(page);
40
+ }
41
+ finally {
42
+ ctx.off('page', observe);
43
+ if (diagnostics.length) {
44
+ try {
45
+ await info.attach('browser-diagnostics', { body: Buffer.from(JSON.stringify(diagnostics)), contentType: 'application/json' });
46
+ }
47
+ catch {
48
+ // Preserve the test result if Playwright has already closed its attachment store.
49
+ }
50
+ }
51
+ }
52
+ },
53
+ });
54
+ /**
55
+ * Playwright fixture that records navigations and named operation timings.
56
+ * Import as `test` in suites that need page and operation-level measurements.
57
+ */
58
+ export const loadTest = diagnosticsTest.extend({
59
+ load: [
60
+ async ({ page, context: ctx }, use, info) => {
61
+ const cfgdEnabled = info.config.metadata?.terraLoadTestingEnabled;
62
+ const enabled = typeof cfgdEnabled === 'boolean' ? cfgdEnabled : readLoadTestingConfig().enabled;
63
+ const measurements = [];
64
+ const pending = [];
65
+ let testBudget;
66
+ const observe = (tgt) => {
67
+ tgt.on('load', () => {
68
+ if (!enabled || tgt.url() === 'about:blank')
69
+ return;
70
+ const pendingLoad = tgt
71
+ .evaluate(() => {
72
+ const entry = performance.getEntries().find((i) => String(i.entryType) === 'navigation');
73
+ return entry ? { durationMs: entry.duration, pathname: location.pathname } : undefined;
74
+ })
75
+ .then((navigation) => {
76
+ if (!navigation || navigation.durationMs <= 0)
77
+ return;
78
+ measurements.push({ name: `page load: ${navigation.pathname || '/'}`, kind: 'page', durationMs: navigation.durationMs });
79
+ })
80
+ .catch(() => undefined);
81
+ pending.push(pendingLoad);
82
+ });
83
+ };
84
+ observe(page);
85
+ ctx.on('page', observe);
86
+ const controller = {
87
+ setTestBudget: (budget) => {
88
+ testBudget = validateLoadBudgetOverride(budget);
89
+ },
90
+ measure: async (name, op, budget) => {
91
+ if (!enabled)
92
+ return op();
93
+ return timeLoadOperation(name, op, (measurement) => {
94
+ measurements.push(measurement);
95
+ }, budget);
96
+ },
97
+ };
98
+ try {
99
+ await use(controller);
100
+ }
101
+ finally {
102
+ ctx.off('page', observe);
103
+ await Promise.all(pending);
104
+ if (enabled && (measurements.length || testBudget)) {
105
+ try {
106
+ await info.attach('load-testing', {
107
+ body: Buffer.from(JSON.stringify({ measurements, testBudget })),
108
+ contentType: 'application/json',
109
+ });
110
+ }
111
+ catch {
112
+ // Preserve the test result if Playwright has already closed its attachment store.
113
+ }
114
+ }
115
+ }
116
+ },
117
+ { auto: true },
118
+ ],
119
+ });
120
+ /**
121
+ * Playwright fixture for collecting any number of named screenshots from a test.
122
+ * The shared reporter matches equal keys across responsive projects.
123
+ */
124
+ export const visualTest = loadTest.extend({
125
+ visuals: async ({ page }, use, info) => {
126
+ const captures = [];
127
+ const keys = new Set();
128
+ await use({
129
+ capture: async (key, options = {}) => {
130
+ const normalizedKey = key.trim();
131
+ if (!normalizedKey)
132
+ throw new Error('A visual capture key is required.');
133
+ if (keys.has(normalizedKey))
134
+ throw new Error(`Duplicate visual capture key: ${normalizedKey}`);
135
+ keys.add(normalizedKey);
136
+ const attachmentName = `visual-capture-${String(captures.length + 1).padStart(5, '0')}`;
137
+ const screenshotOptions = {
138
+ type: 'jpeg',
139
+ quality: 65,
140
+ fullPage: false,
141
+ animations: 'disabled',
142
+ caret: 'hide',
143
+ ...options.screenshot,
144
+ };
145
+ const extension = screenshotOptions.type === 'png' ? 'png' : 'jpg';
146
+ const contentType = screenshotOptions.type === 'png' ? 'image/png' : 'image/jpeg';
147
+ const screenshotPath = info.outputPath(`${attachmentName}.${extension}`);
148
+ await fs.promises.writeFile(screenshotPath, await page.screenshot(screenshotOptions));
149
+ await info.attach(attachmentName, { path: screenshotPath, contentType });
150
+ captures.push({ attachmentName, contentType, key: normalizedKey, title: options.title?.trim() || normalizedKey });
151
+ },
152
+ });
153
+ if (captures.length) {
154
+ try {
155
+ await info.attach('visual-captures', { body: Buffer.from(JSON.stringify(captures)), contentType: 'application/json' });
156
+ }
157
+ catch {
158
+ // Preserve the test result if Playwright has already closed its attachment store.
159
+ }
160
+ }
161
+ },
162
+ });
163
+ /*//////////////////////////////////////////////////
164
+ HELPERS
165
+ //////////////////////////////////////////////////*/
166
+ /**
167
+ * Removes terminal color escapes before diagnostics enter an artifact.
168
+ * @param valerminal output that may contain ANSI escape sequences.
169
+ * @returns Terminal output with ANSI formatting removed.
170
+ */
171
+ export const stripAnsi = (val) => val.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, 'g'), '');
172
+ /**
173
+ * Parses browser diagnostic attachments into readable artifact lines.
174
+ * @param raw JSON attachment text or a plain diagnostic message.
175
+ * @returns Normalized diagnostic lines.
176
+ */
177
+ export const parseDiagnostics = (raw) => {
178
+ if (!raw)
179
+ return [];
180
+ try {
181
+ const parsed = JSON.parse(raw);
182
+ if (!Array.isArray(parsed))
183
+ return [];
184
+ return parsed.flatMap((v) => {
185
+ if (!v || typeof v !== 'object')
186
+ return [];
187
+ const rec = v;
188
+ return rec.message ? [`${rec.level || 'diagnostic'}: ${rec.message}${rec.url ? ` (${rec.url})` : ''}`] : [];
189
+ });
190
+ }
191
+ catch {
192
+ return [raw];
193
+ }
194
+ };
195
+ /**
196
+ * Normalizes a terminal output chunk for diagnostic matching and storage.
197
+ * @param chunk Terminal text or bytes emitted by Playwright.
198
+ * @returns Trimmed, ANSI-free terminal text.
199
+ */
200
+ const terminalText = (chunk) => stripAnsi(chunk.toString('utf8')).trim();
201
+ /**
202
+ * Identifies terminal output that should be retained as diagnostic evidence.
203
+ * @param valormalized terminal output.
204
+ * @returns Whether the output contains a diagnostic keyword.
205
+ */
206
+ const isTerminalDiagnostic = (val) => /\b(?:error|errors|failed|failure|exception|warning|warn|timeout|requestfailed|pageerror)\b/i.test(val);
207
+ /**
208
+ * Reads an inline or file-backed Playwright attachment.
209
+ * @param attachment Attachment metadata supplied by Playwright.
210
+ * @returns Attachment text, or an empty string when it is unavailable.
211
+ */
212
+ const readAttachment = (attachment) => {
213
+ if (attachment.body)
214
+ return attachment.body.toString('utf8');
215
+ if (attachment.path && fs.existsSync(attachment.path))
216
+ return fs.readFileSync(attachment.path, 'utf8');
217
+ return '';
218
+ };
219
+ /**
220
+ * Extracts one named JSON diagnostic attachment.
221
+ * @param attachments Attachments emitted for one test result.
222
+ * @param name Diagnostic attachment name to locate.
223
+ * @returns Parsed diagnostic lines from the matching attachment.
224
+ */
225
+ const diagnosticText = (attachments, name) => {
226
+ const attachment = attachments.find((v) => v.name === name && v.contentType === 'application/json');
227
+ const raw = attachment ? readAttachment(attachment) : '';
228
+ if (!raw)
229
+ return [];
230
+ return parseDiagnostics(raw);
231
+ };
232
+ /**
233
+ * Resolves the path of a captured screenshot attachment.
234
+ * @param attachments Attachments emitted for one test result.
235
+ * @returns Screenshot path, or an empty string when no screenshot exists.
236
+ */
237
+ const screenshotAttachment = (attachments) => attachments.find((v) => ['visual', 'screenshot'].includes(v.name || '') && v.contentType === 'image/png');
238
+ /** Saves one capture under a stable identity so reruns replace it without duplicating the deck. */
239
+ const persistScreenshotCapture = (directory, capture) => {
240
+ if (!capture.screenshot || !fs.existsSync(capture.screenshot))
241
+ return capture;
242
+ const key = createHash('sha256').update([capture.group, capture.key, capture.viewport].join('\0')).digest('hex');
243
+ const extension = path.extname(capture.screenshot) || '.png';
244
+ const screenshot = path.join(directory, `${key}${extension}`);
245
+ const recordFile = path.join(directory, `${key}.json`);
246
+ fs.mkdirSync(directory, { recursive: true });
247
+ if (fs.existsSync(recordFile)) {
248
+ try {
249
+ const previous = JSON.parse(fs.readFileSync(recordFile, 'utf8'));
250
+ if ((previous.retry || 0) > (capture.retry || 0))
251
+ return { ...previous, screenshot };
252
+ }
253
+ catch {
254
+ // Replace an incomplete record with the next valid result.
255
+ }
256
+ }
257
+ const temporaryImage = `${screenshot}.${process.pid}.tmp`;
258
+ fs.copyFileSync(capture.screenshot, temporaryImage);
259
+ fs.renameSync(temporaryImage, screenshot);
260
+ const persisted = { ...capture, screenshot };
261
+ const temporaryRecord = `${recordFile}.${process.pid}.tmp`;
262
+ fs.writeFileSync(temporaryRecord, JSON.stringify(persisted));
263
+ fs.renameSync(temporaryRecord, recordFile);
264
+ return persisted;
265
+ };
266
+ /** Validates the keyed-capture manifest emitted by the `visualTest` fixture. */
267
+ const visualCaptureManifest = (attachments) => {
268
+ const attachment = attachments.find((item) => item.name === 'visual-captures' && item.contentType === 'application/json');
269
+ if (!attachment)
270
+ return [];
271
+ try {
272
+ const parsed = JSON.parse(readAttachment(attachment));
273
+ if (!Array.isArray(parsed))
274
+ return [];
275
+ return parsed.flatMap((item) => {
276
+ if (!item || typeof item !== 'object')
277
+ return [];
278
+ const row = item;
279
+ if (typeof row.attachmentName !== 'string' || typeof row.contentType !== 'string' || typeof row.key !== 'string' || typeof row.title !== 'string')
280
+ return [];
281
+ return [{ attachmentName: row.attachmentName, contentType: row.contentType, key: row.key, title: row.title }];
282
+ });
283
+ }
284
+ catch {
285
+ return [];
286
+ }
287
+ };
288
+ /** Reads timing samples and a test override from the fixture attachment. */
289
+ const loadAttachment = (attachments) => {
290
+ const attachment = attachments.find((v) => v.name === 'load-testing' && v.contentType === 'application/json');
291
+ if (!attachment)
292
+ return { measurements: [] };
293
+ try {
294
+ const parsed = JSON.parse(readAttachment(attachment));
295
+ const measurements = Array.isArray(parsed.measurements)
296
+ ? parsed.measurements.flatMap((v) => {
297
+ if (!v || typeof v !== 'object')
298
+ return [];
299
+ const row = v;
300
+ if (typeof row.name !== 'string' || !row.name.trim() || !['page', 'operation'].includes(String(row.kind)))
301
+ return [];
302
+ if (typeof row.durationMs !== 'number' || !Number.isFinite(row.durationMs) || row.durationMs < 0)
303
+ return [];
304
+ let budget;
305
+ if (row.budget && typeof row.budget === 'object')
306
+ budget = validateLoadBudgetOverride(row.budget);
307
+ return [{ name: redactEvidence(row.name), kind: row.kind, durationMs: row.durationMs, ...(budget ? { budget } : {}) }];
308
+ })
309
+ : [];
310
+ const testBudget = parsed.testBudget && typeof parsed.testBudget === 'object' ? validateLoadBudgetOverride(parsed.testBudget) : undefined;
311
+ return { measurements, ...(testBudget ? { testBudget } : {}) };
312
+ }
313
+ catch {
314
+ return { measurements: [] };
315
+ }
316
+ };
317
+ /** Grades the test duration and attached page/operation samples for one result. */
318
+ const addLoadMeasurements = (result, id, inputs, testBudget, cfg) => {
319
+ if (!cfg.enabled)
320
+ return result;
321
+ const measurements = [
322
+ gradeLoadMeasurement({ name: 'test duration', kind: 'test', durationMs: result.duration, ...(testBudget ? { budget: testBudget } : {}) }, cfg, id),
323
+ ...inputs.map((input) => gradeLoadMeasurement(input, cfg, id)),
324
+ ];
325
+ return { ...result, loadMeasurements: measurements, loadTestId: id };
326
+ };
327
+ /**
328
+ * Creates the framework-neutral identity fields shared by all result records.
329
+ * @param test Playwright test identity.
330
+ * @param cfgdProjectgured Playwright project name.
331
+ * @param viewportCatalog Catalog used to resolve viewport labels and sizes.
332
+ * @returns Framework-neutral identity fields for a visual result.
333
+ */
334
+ const testIdentity = (test, cfgdProject, viewportCatalog) => {
335
+ const file = path.basename(test.location.file);
336
+ const fullTitlePath = test.titlePath();
337
+ const fileIdx = fullTitlePath.findIndex((v) => v.endsWith(file));
338
+ const titlePath = fullTitlePath.slice(fileIdx >= 0 ? fileIdx + 1 : 0);
339
+ const viewport = projectViewportName(cfgdProject, viewportCatalog);
340
+ return {
341
+ file: redactEvidence(file),
342
+ groups: redactEvidence(titlePath.join(' › ')),
343
+ project: redactEvidence(baseVisualProjectName(cfgdProject, viewportCatalog)),
344
+ title: redactEvidence(titlePath.pop() || test.title),
345
+ viewport,
346
+ viewportSize: viewportSizeLabel(viewport, viewportCatalog),
347
+ };
348
+ };
349
+ /** Resolves the title path portion used by the stable test budget key. */
350
+ const testTitleParts = (test) => {
351
+ const file = path.basename(test.location.file);
352
+ const fullTitlePath = test.titlePath();
353
+ const fileIdx = fullTitlePath.findIndex((v) => v.endsWith(file));
354
+ return fullTitlePath.slice(fileIdx >= 0 ? fileIdx + 1 : 0).filter(Boolean);
355
+ };
356
+ /**
357
+ * Generates a stable filename for a copied screenshot.
358
+ * @param result Result whose identity contributes to the filename.
359
+ * @param idxesult position used to keep duplicate names distinct.
360
+ * @returns A filesystem-safe screenshot filename.
361
+ */
362
+ const screenshotName = (result, idx) => {
363
+ return `${String(idx + 1).padStart(4, '0')}-${`${result.project}-${result.file}-${result.title}-${result.viewport}`
364
+ .replace(/[^a-z0-9_-]+/gi, '-')
365
+ .replace(/^-+|-+$/g, '')
366
+ .toLowerCase() || 'screenshot'}.png`;
367
+ };
368
+ /**
369
+ * Copies captured screenshots into the timestamped run directory.
370
+ * @param results Results whose screenshot paths should be relocated.
371
+ * @param runDirtamped output directory for the current run.
372
+ * @returns Results with copied screenshot paths.
373
+ */
374
+ const copyScreenshots = (results, runDir) => {
375
+ const imgs = path.join(runDir, 'images');
376
+ fs.mkdirSync(imgs, { recursive: true });
377
+ return results.map((r, i) => {
378
+ if (!r.screenshot || !fs.existsSync(r.screenshot))
379
+ return r;
380
+ const dest = path.join(imgs, screenshotName(r, i));
381
+ fs.copyFileSync(r.screenshot, dest);
382
+ return { ...r, screenshot: dest };
383
+ });
384
+ };
385
+ /** Redacts personal identifiers and configured secrets from report text. */
386
+ const redactEvidence = (val) => {
387
+ let text = Array.from(stripAnsi(String(val ?? '')))
388
+ .filter((c) => {
389
+ const code = c.codePointAt(0) ?? 0;
390
+ return code === 9 || code === 10 || code === 13 || (code > 31 && code !== 0xfffe && code !== 0xffff);
391
+ })
392
+ .join('')
393
+ .replace(/[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}/g, '[redacted email]')
394
+ .replace(/\b[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\b/gi, '[redacted id]');
395
+ for (const [name, secret] of Object.entries(process.env)) {
396
+ if (secret && /(?:secret|token|password|credential|private.?key|api.?key|publishable.?key|service[_-]?role.?key)/i.test(name))
397
+ text = text.replaceAll(secret, '[redacted secret]');
398
+ }
399
+ return text.replaceAll(process.cwd(), '[workspace]');
400
+ };
401
+ /** Converts untrusted supplemental timing values to supported measurement inputs. */
402
+ const supplementalMeasurements = (val) => {
403
+ if (!Array.isArray(val))
404
+ return [];
405
+ return val.flatMap((e) => {
406
+ if (!e || typeof e !== 'object')
407
+ return [];
408
+ const row = e;
409
+ if (typeof row.name !== 'string' || !row.name.trim() || !['page', 'operation'].includes(String(row.kind)))
410
+ return [];
411
+ if (typeof row.durationMs !== 'number' || !Number.isFinite(row.durationMs) || row.durationMs < 0)
412
+ return [];
413
+ let budget;
414
+ try {
415
+ if (row.budget && typeof row.budget === 'object')
416
+ budget = validateLoadBudgetOverride(row.budget);
417
+ }
418
+ catch {
419
+ return [];
420
+ }
421
+ return [{ name: redactEvidence(row.name), kind: row.kind, durationMs: row.durationMs, ...(budget ? { budget } : {}) }];
422
+ });
423
+ };
424
+ /** Converts an optional structured results file into the shared row model. */
425
+ const supplementalResults = (file, loadTesting) => {
426
+ const unavailable = (err) => ({
427
+ browserErrors: '',
428
+ duration: 0,
429
+ error: err,
430
+ file: path.basename(file),
431
+ groups: 'Supplemental results',
432
+ project: 'supplemental',
433
+ retry: 0,
434
+ screenshot: '',
435
+ status: 'interrupted',
436
+ terminalErrors: '',
437
+ title: 'Structured supplemental results unavailable',
438
+ visual: 'No visual surface',
439
+ viewport: 'supplemental',
440
+ viewportSize: 'N/A',
441
+ });
442
+ if (!fs.existsSync(file))
443
+ return { results: [unavailable(`Supplemental results file was not found: ${file}`)], diagnostics: [] };
444
+ try {
445
+ const payload = JSON.parse(fs.readFileSync(file, 'utf8'));
446
+ const rows = Array.isArray(payload.results) ? payload.results : [];
447
+ const results = rows.map((v) => {
448
+ const row = v && typeof v === 'object' ? v : {};
449
+ const rawDuration = Number(row.duration ?? row.duration_ms ?? 0);
450
+ const result = {
451
+ browserErrors: '',
452
+ duration: Number.isFinite(rawDuration) && rawDuration >= 0 ? rawDuration : 0,
453
+ error: redactEvidence(row.error),
454
+ file: redactEvidence(row.file || path.basename(file)),
455
+ groups: redactEvidence(row.groups || row.file || 'Supplemental results'),
456
+ project: redactEvidence(row.project || 'supplemental'),
457
+ retry: Number(row.retry ?? 0) || 0,
458
+ screenshot: '',
459
+ status: normalizeVisualStatus(String(row.status || 'failed')),
460
+ terminalErrors: '',
461
+ title: redactEvidence(row.title || 'Untitled supplemental result'),
462
+ visual: 'No visual surface',
463
+ viewport: 'supplemental',
464
+ viewportSize: 'N/A',
465
+ };
466
+ if (!loadTesting.enabled)
467
+ return result;
468
+ const rawFile = String(row.file || path.basename(file));
469
+ const titlePath = [String(row.groups || ''), String(row.title || 'Untitled supplemental result')].filter(Boolean).flatMap((part) => part.split(' › '));
470
+ const id = String(row.loadTestId || row.testId || loadTestId(rawFile, titlePath, loadTesting.root));
471
+ const testBudget = row.testBudget && typeof row.testBudget === 'object' ? validateLoadBudgetOverride(row.testBudget) : undefined;
472
+ return addLoadMeasurements(result, id, supplementalMeasurements(row.loadMeasurements), testBudget, loadTesting);
473
+ });
474
+ const diagnostics = Array.isArray(payload.runDiagnostics) ? payload.runDiagnostics.map(redactEvidence) : [];
475
+ if (payload.complete === false)
476
+ results.unshift(unavailable('The supplemental test run reported an incomplete result inventory.'));
477
+ return { results, diagnostics };
478
+ }
479
+ catch (err) {
480
+ return { results: [unavailable(`Could not read supplemental results: ${err instanceof Error ? err.message : String(err)}`)], diagnostics: [] };
481
+ }
482
+ };
483
+ /** Creates the reusable Playwright reporter with application-specific output configuration. */
484
+ export default class ProgressReporter {
485
+ options;
486
+ results = [];
487
+ screenshotCaptures = [];
488
+ terminalDiagnostics = new Map();
489
+ runDiagnostics = [];
490
+ expectedTests = new Map();
491
+ completedTests = new Set();
492
+ /**
493
+ * Creates a reporter that writes application-specific visual test artifacts.
494
+ *
495
+ * @param opts Application-specific reporter configuration.
496
+ */
497
+ constructor(opts = {}) {
498
+ this.options = {
499
+ featureFiles: opts.featureFiles || [],
500
+ projects: opts.projects || [],
501
+ viewportCatalog: opts.viewportCatalog || VISUAL_VIEWPORTS,
502
+ outputRoot: opts.outputRoot || 'test-results/visual',
503
+ screenshotsDirectory: opts.screenshotsDirectory || path.join(opts.outputRoot || 'test-results/visual', 'screenshots'),
504
+ reportFile: opts.reportFile || 'report.xlsx',
505
+ slideshowFile: opts.slideshowFile || 'report.pptx',
506
+ supplementalResultsFile: opts.supplementalResultsFile,
507
+ loadTesting: readLoadTestingConfig(opts.qualityConfigFile, opts.loadTesting),
508
+ screenshotOnlySlideshow: opts.screenshotOnlySlideshow ?? process.env.TERRA_SCREENSHOT_ONLY_SLIDESHOW !== '0',
509
+ };
510
+ }
511
+ /**
512
+ * Records the full expected suite so interrupted tests can be represented.
513
+ * @param _config Playwright configuration supplied by the reporter lifecycle.
514
+ * @param suite Playwright suite containing all discovered tests.
515
+ * @returns Nothing; the expected-test registry is updated in place.
516
+ */
517
+ onBegin(_config, suite) {
518
+ for (const test of suite.allTests())
519
+ this.expectedTests.set(test.id, test);
520
+ if (!this.options.featureFiles.length || !this.options.projects.length) {
521
+ const projectFiles = new Map();
522
+ const files = new Set();
523
+ for (const test of suite.allTests()) {
524
+ const file = path.basename(test.location.file);
525
+ const cfgd = test.parent.project()?.name || test.titlePath().find((v) => v.startsWith('root-')) || 'unknown';
526
+ const project = baseVisualProjectName(cfgd, this.options.viewportCatalog);
527
+ files.add(file);
528
+ projectFiles.set(project, new Set([...(projectFiles.get(project) || []), file]));
529
+ }
530
+ if (!this.options.featureFiles.length)
531
+ this.options.featureFiles = [...files];
532
+ if (!this.options.projects.length)
533
+ this.options.projects = [...projectFiles].map(([n, c]) => ({ name: n, configuredFiles: [...c] }));
534
+ }
535
+ }
536
+ /**
537
+ * Associates stderr with a test result or the run when no test is active.
538
+ * @param chunk Terminal output emitted by Playwright.
539
+ * @param test Active test identity, when available.
540
+ * @param result Active test result, when available.
541
+ * @returns Nothing; diagnostic state is updated in place.
542
+ */
543
+ onStdErr(chunk, test, result) {
544
+ const val = terminalText(chunk);
545
+ if (!val)
546
+ return;
547
+ if (!test || !result) {
548
+ this.runDiagnostics.push(val);
549
+ return;
550
+ }
551
+ this.terminalDiagnostics.set(result, [...(this.terminalDiagnostics.get(result) || []), val]);
552
+ }
553
+ /**
554
+ * Retains diagnostic-looking stdout without treating ordinary output as failure evidence.
555
+ * @param chunk Terminal output emitted by Playwright.
556
+ * @param test Active test identity, when available.
557
+ * @param result Active test result, when available.
558
+ * @returns Nothing; diagnostic state is updated in place.
559
+ */
560
+ onStdOut(chunk, test, result) {
561
+ const val = terminalText(chunk);
562
+ if (!val || !isTerminalDiagnostic(val))
563
+ return;
564
+ if (!test || !result) {
565
+ this.runDiagnostics.push(val);
566
+ return;
567
+ }
568
+ this.terminalDiagnostics.set(result, [...(this.terminalDiagnostics.get(result) || []), val]);
569
+ }
570
+ /**
571
+ * Retains reporter-level errors in the run diagnostics.
572
+ * @param err Reporter-level error emitted by Playwright.
573
+ * @returns Nothing; run diagnostics are updated in place.
574
+ */
575
+ onError(err) {
576
+ this.runDiagnostics.push(err.message);
577
+ }
578
+ /**
579
+ * Normalizes a completed Playwright result and associates its evidence.
580
+ * @param test Completed Playwright test case.
581
+ * @param result Completed Playwright test result and attachments.
582
+ * @returns Nothing; the normalized result is appended to the run.
583
+ */
584
+ onTestEnd(test, result) {
585
+ this.completedTests.add(test.id);
586
+ const attachment = screenshotAttachment(result.attachments);
587
+ let screenshot = attachment?.path || '';
588
+ if (!screenshot && attachment?.body) {
589
+ screenshot = path.resolve(`test-results/playwright-output/${test.id.replace(/[^a-z0-9_-]/gi, '-')}-screenshot.png`);
590
+ fs.mkdirSync(path.dirname(screenshot), { recursive: true });
591
+ fs.writeFileSync(screenshot, attachment.body);
592
+ }
593
+ const titleParts = testTitleParts(test);
594
+ const testId = loadTestId(test.location.file, titleParts, this.options.loadTesting.root);
595
+ const attachedLoad = loadAttachment(result.attachments);
596
+ const norm = {
597
+ ...testIdentity(test, test.parent.project()?.name || test.titlePath().find((v) => v.startsWith('root-')) || 'unknown', this.options.viewportCatalog),
598
+ browserErrors: redactEvidence(diagnosticText(result.attachments, 'browser-diagnostics').join('\n')),
599
+ duration: result.duration,
600
+ error: redactEvidence(result.error?.message?.split('\n')[0] || ''),
601
+ retry: result.retry,
602
+ screenshot,
603
+ status: normalizeVisualStatus(result.status),
604
+ terminalErrors: redactEvidence(this.terminalDiagnostics.get(result)?.join('\n') || ''),
605
+ visual: screenshot ? 'Embedded' : result.attachments.some((v) => v.name === 'visual-error') ? 'Capture failed' : 'No visual surface',
606
+ };
607
+ this.results.push(addLoadMeasurements(norm, testId, attachedLoad.measurements, attachedLoad.testBudget, this.options.loadTesting));
608
+ const identity = testIdentity(test, test.parent.project()?.name || test.titlePath().find((v) => v.startsWith('root-')) || 'unknown', this.options.viewportCatalog);
609
+ const stableTestGroup = [identity.project, path.relative(process.cwd(), test.location.file), identity.groups, identity.title].join('\u0000');
610
+ if (screenshot) {
611
+ this.screenshotCaptures.push(persistScreenshotCapture(path.resolve(this.options.screenshotsDirectory), {
612
+ group: `${stableTestGroup}\u0000test-result`,
613
+ key: 'test-result',
614
+ title: identity.title,
615
+ viewport: identity.viewport,
616
+ viewportSize: identity.viewportSize,
617
+ screenshot,
618
+ retry: result.retry,
619
+ }));
620
+ }
621
+ for (const capture of visualCaptureManifest(result.attachments)) {
622
+ const image = result.attachments.find((item) => item.name === capture.attachmentName && item.contentType === capture.contentType);
623
+ if (!image) {
624
+ this.runDiagnostics.push(`Missing screenshot attachment ${capture.attachmentName} for ${capture.key} (${identity.viewport}).`);
625
+ continue;
626
+ }
627
+ let capturePath = image.path || '';
628
+ if (!capturePath && image.body) {
629
+ const extension = capture.contentType === 'image/png' ? 'png' : 'jpg';
630
+ capturePath = path.resolve('test-results/playwright-output', `${test.id.replace(/[^a-z0-9_-]/gi, '-')}-${capture.attachmentName}.${extension}`);
631
+ fs.mkdirSync(path.dirname(capturePath), { recursive: true });
632
+ fs.writeFileSync(capturePath, image.body);
633
+ }
634
+ if (!capturePath) {
635
+ this.runDiagnostics.push(`Screenshot data unavailable for ${capture.key} (${identity.viewport}).`);
636
+ continue;
637
+ }
638
+ this.screenshotCaptures.push(persistScreenshotCapture(path.resolve(this.options.screenshotsDirectory), {
639
+ group: `${stableTestGroup}\u0000${capture.key}`,
640
+ key: capture.key,
641
+ title: redactEvidence(capture.title),
642
+ viewport: identity.viewport,
643
+ viewportSize: identity.viewportSize,
644
+ screenshot: capturePath,
645
+ retry: result.retry,
646
+ }));
647
+ }
648
+ this.terminalDiagnostics.delete(result);
649
+ }
650
+ /**
651
+ * Emits interrupted results and writes the spreadsheet and slideshow artifacts.
652
+ * @returns A promise that resolves after both report artifacts are written.
653
+ */
654
+ async onEnd() {
655
+ if (process.argv.includes('--list'))
656
+ return;
657
+ for (const [id, test] of this.expectedTests) {
658
+ if (this.completedTests.has(id))
659
+ continue;
660
+ this.results.push({
661
+ ...testIdentity(test, test.parent.project()?.name || test.titlePath().find((v) => v.startsWith('root-')) || 'unknown', this.options.viewportCatalog),
662
+ browserErrors: '',
663
+ duration: 0,
664
+ error: 'Test did not start because an upstream dependency failed or the run was interrupted.',
665
+ retry: 0,
666
+ screenshot: '',
667
+ status: 'interrupted',
668
+ terminalErrors: '',
669
+ visual: 'No visual surface',
670
+ });
671
+ }
672
+ const runDir = path.resolve(this.options.outputRoot, new Date().toISOString().replace(/[:.]/g, '-'));
673
+ fs.mkdirSync(runDir, { recursive: true });
674
+ const browserResults = copyScreenshots(this.results, runDir);
675
+ const supplemental = this.options.supplementalResultsFile ? supplementalResults(path.resolve(this.options.supplementalResultsFile), this.options.loadTesting) : { results: [], diagnostics: [] };
676
+ const results = [...browserResults, ...supplemental.results];
677
+ const run = {
678
+ featureFiles: [...new Set([...this.options.featureFiles, ...supplemental.results.map((r) => r.file)])],
679
+ projects: [
680
+ ...this.options.projects,
681
+ ...[
682
+ ...supplemental.results.reduce((p, r) => {
683
+ p.set(r.project, new Set([...(p.get(r.project) || []), r.file]));
684
+ return p;
685
+ }, new Map()),
686
+ ].map(([n, c]) => ({ name: n, configuredFiles: [...c] })),
687
+ ],
688
+ results,
689
+ runDiagnostics: [...this.runDiagnostics.map(redactEvidence), ...supplemental.diagnostics],
690
+ loadTestingEnabled: this.options.loadTesting.enabled,
691
+ };
692
+ const spreadsheet = path.join(runDir, this.options.reportFile);
693
+ const slideshow = path.join(runDir, this.options.slideshowFile);
694
+ await writeSpreadsheet(spreadsheet, run);
695
+ if (this.options.screenshotOnlySlideshow) {
696
+ await writeScreenshotSlideshowFromDirectory(path.resolve(this.options.screenshotsDirectory), slideshow);
697
+ }
698
+ else
699
+ await writeSlideshow(slideshow, this.options.loadTesting.enabled ? results : browserResults, { loadTestingEnabled: this.options.loadTesting.enabled });
700
+ process.stdout.write(`Test spreadsheet: ${spreadsheet}\nTest slideshow: ${slideshow}\n`);
701
+ const measurements = results.flatMap((r) => r.loadMeasurements || []);
702
+ const loadCounts = loadGradeCounts(measurements);
703
+ if (this.options.loadTesting.enabled)
704
+ process.stdout.write(`Load budgets: ${loadCounts.pass} pass, ${loadCounts.warning} warning, ${loadCounts.fail} fail.\n`);
705
+ return loadCounts.fail ? 'failed' : undefined;
706
+ }
707
+ }
708
+ export { diagnosticCount };
709
+ //# sourceMappingURL=playwright.js.map