donobu 5.1.0 → 5.2.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.
Files changed (35) hide show
  1. package/dist/cli/donobu-cli.js +61 -3
  2. package/dist/cli/donobu-cli.js.map +1 -1
  3. package/dist/cli/playwright-json-to-html.d.ts +17 -0
  4. package/dist/cli/playwright-json-to-html.d.ts.map +1 -0
  5. package/dist/cli/playwright-json-to-html.js +1278 -0
  6. package/dist/cli/playwright-json-to-html.js.map +1 -0
  7. package/dist/esm/cli/donobu-cli.js +61 -3
  8. package/dist/esm/cli/donobu-cli.js.map +1 -1
  9. package/dist/esm/cli/playwright-json-to-html.d.ts +17 -0
  10. package/dist/esm/cli/playwright-json-to-html.d.ts.map +1 -0
  11. package/dist/esm/cli/playwright-json-to-html.js +1278 -0
  12. package/dist/esm/cli/playwright-json-to-html.js.map +1 -0
  13. package/dist/esm/lib/test/testExtension.d.ts.map +1 -1
  14. package/dist/esm/lib/test/testExtension.js +59 -0
  15. package/dist/esm/lib/test/testExtension.js.map +1 -1
  16. package/dist/esm/lib/test/utils/triageTestFailure.d.ts +1 -0
  17. package/dist/esm/lib/test/utils/triageTestFailure.d.ts.map +1 -1
  18. package/dist/esm/lib/test/utils/triageTestFailure.js +13 -0
  19. package/dist/esm/lib/test/utils/triageTestFailure.js.map +1 -1
  20. package/dist/esm/persistence/env/EnvPersistenceRegistry.d.ts +8 -0
  21. package/dist/esm/persistence/env/EnvPersistenceRegistry.d.ts.map +1 -1
  22. package/dist/esm/persistence/env/EnvPersistenceRegistry.js +16 -0
  23. package/dist/esm/persistence/env/EnvPersistenceRegistry.js.map +1 -1
  24. package/dist/lib/test/testExtension.d.ts.map +1 -1
  25. package/dist/lib/test/testExtension.js +59 -0
  26. package/dist/lib/test/testExtension.js.map +1 -1
  27. package/dist/lib/test/utils/triageTestFailure.d.ts +1 -0
  28. package/dist/lib/test/utils/triageTestFailure.d.ts.map +1 -1
  29. package/dist/lib/test/utils/triageTestFailure.js +13 -0
  30. package/dist/lib/test/utils/triageTestFailure.js.map +1 -1
  31. package/dist/persistence/env/EnvPersistenceRegistry.d.ts +8 -0
  32. package/dist/persistence/env/EnvPersistenceRegistry.d.ts.map +1 -1
  33. package/dist/persistence/env/EnvPersistenceRegistry.js +16 -0
  34. package/dist/persistence/env/EnvPersistenceRegistry.js.map +1 -1
  35. package/package.json +3 -2
@@ -0,0 +1,1278 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * @fileoverview Playwright JSON Report to HTML Report Converter
5
+ *
6
+ * Converts Playwright JSON test reports (optionally enriched with Donobu triage
7
+ * data) into a polished, self-contained HTML report for test writers, maintainers,
8
+ * and debuggers.
9
+ *
10
+ * @usage
11
+ * ```bash
12
+ * npm exec playwright-json-to-html report.json -o report.html
13
+ * npm exec playwright-json-to-html report.json --triage-dir ./donobu-triage/run-id/ -o report.html
14
+ * cat merged-report.json | npx playwright-json-to-html --triage-dir ./triage/ -o report.html
15
+ * ```
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ const fs_1 = require("fs");
19
+ const path_1 = require("path");
20
+ // ---------------------------------------------------------------------------
21
+ // Helpers
22
+ // ---------------------------------------------------------------------------
23
+ function stripAnsi(str) {
24
+ return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
25
+ }
26
+ function esc(str) {
27
+ return str
28
+ .replace(/&/g, '&amp;')
29
+ .replace(/</g, '&lt;')
30
+ .replace(/>/g, '&gt;')
31
+ .replace(/"/g, '&quot;')
32
+ .replace(/'/g, '&#039;');
33
+ }
34
+ function fmtDuration(ms) {
35
+ if (ms < 1000) {
36
+ return `${ms}ms`;
37
+ }
38
+ const s = Math.floor(ms / 1000);
39
+ if (s < 60) {
40
+ return `${s}s`;
41
+ }
42
+ return `${Math.floor(s / 60)}m ${s % 60}s`;
43
+ }
44
+ function fmtPercent(n) {
45
+ return `${Math.round(n * 100)}%`;
46
+ }
47
+ function uid() {
48
+ return Math.random().toString(36).slice(2, 10);
49
+ }
50
+ /** Normalize a file path to just the basename for matching purposes. */
51
+ function normalizeTestFile(filePath) {
52
+ if (!filePath) {
53
+ return '';
54
+ }
55
+ // Strip to just the relative-style path: "tests/foo.test.ts"
56
+ // Handle both absolute and relative paths
57
+ const match = filePath.match(/(?:^|[/\\])(tests[/\\].+)$/);
58
+ return match ? match[1].replace(/\\/g, '/') : (0, path_1.basename)(filePath);
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // CLI argument parsing
62
+ // ---------------------------------------------------------------------------
63
+ function parseArgs() {
64
+ const args = process.argv.slice(2);
65
+ let outputFile = null;
66
+ let triageDir = null;
67
+ let inputFile = null;
68
+ for (let i = 0; i < args.length; i++) {
69
+ if ((args[i] === '-o' || args[i] === '--output') && i + 1 < args.length) {
70
+ outputFile = args[i + 1];
71
+ i++;
72
+ }
73
+ else if (args[i] === '--triage-dir' && i + 1 < args.length) {
74
+ triageDir = args[i + 1];
75
+ i++;
76
+ }
77
+ else if (!args[i].startsWith('-')) {
78
+ inputFile = args[i];
79
+ }
80
+ }
81
+ const jsonData = inputFile
82
+ ? JSON.parse((0, fs_1.readFileSync)(inputFile, 'utf8'))
83
+ : JSON.parse((0, fs_1.readFileSync)(0, 'utf8'));
84
+ // Default output to a sibling of the input JSON so asset relative paths work
85
+ if (!outputFile && inputFile) {
86
+ outputFile = (0, path_1.join)((0, path_1.dirname)((0, path_1.resolve)(inputFile)), 'donobu-report.html');
87
+ }
88
+ return {
89
+ jsonData,
90
+ outputFile,
91
+ triageDir: triageDir ? (0, path_1.resolve)(triageDir) : null,
92
+ };
93
+ }
94
+ function loadTriageData(triageDir) {
95
+ const plans = [];
96
+ const evidence = [];
97
+ if (!(0, fs_1.existsSync)(triageDir)) {
98
+ return { plans, evidence };
99
+ }
100
+ try {
101
+ const files = (0, fs_1.readdirSync)(triageDir);
102
+ for (const f of files) {
103
+ const full = (0, path_1.resolve)(triageDir, f);
104
+ try {
105
+ const raw = JSON.parse((0, fs_1.readFileSync)(full, 'utf8'));
106
+ if (f.startsWith('treatment-plan-') && f.endsWith('.json')) {
107
+ plans.push(raw);
108
+ }
109
+ else if (f.startsWith('failure-evidence-') && f.endsWith('.json')) {
110
+ evidence.push(raw);
111
+ }
112
+ }
113
+ catch {
114
+ // Skip unparseable files
115
+ }
116
+ }
117
+ }
118
+ catch {
119
+ // Directory unreadable
120
+ }
121
+ return { plans, evidence };
122
+ }
123
+ /**
124
+ * Build a lookup key from test metadata for matching triage files to tests.
125
+ * Normalizes file paths to handle absolute vs relative mismatches.
126
+ */
127
+ function triageKey(file, projectName, title) {
128
+ return [normalizeTestFile(file), projectName ?? '', title]
129
+ .join('::')
130
+ .toLowerCase();
131
+ }
132
+ function buildTriageLookups(triage) {
133
+ const plansByKey = new Map();
134
+ const evidenceByKey = new Map();
135
+ for (const plan of triage.plans) {
136
+ const tc = plan.failure?.testCase;
137
+ if (tc) {
138
+ plansByKey.set(triageKey(tc.file, tc.projectName, tc.title), plan);
139
+ }
140
+ }
141
+ for (const ev of triage.evidence) {
142
+ const tc = ev.failureContext?.testCase;
143
+ if (tc) {
144
+ evidenceByKey.set(triageKey(tc.file, tc.projectName, tc.title), ev);
145
+ }
146
+ }
147
+ return { plansByKey, evidenceByKey };
148
+ }
149
+ function parseStderrSteps(stderrEntries) {
150
+ const steps = [];
151
+ for (const entry of stderrEntries) {
152
+ const raw = entry.text?.trim();
153
+ if (!raw) {
154
+ continue;
155
+ }
156
+ // Parse timestamp: "HH:MM:SS.mmm [uuid] LEVEL message"
157
+ const m = raw.match(/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+\[[^\]]+\]\s+\w+\s+(.+)$/);
158
+ if (!m) {
159
+ continue;
160
+ }
161
+ const time = m[1];
162
+ const msg = m[2];
163
+ // Categorize the log line
164
+ if (msg.startsWith('Taking action:')) {
165
+ steps.push({
166
+ time,
167
+ text: msg.replace('Taking action: ', ''),
168
+ type: 'action',
169
+ });
170
+ }
171
+ else if (msg.startsWith('The ') && msg.includes(' tool completed in ')) {
172
+ // "The assertPage tool completed in 5054ms with outcome {...}"
173
+ const toolMatch = msg.match(/^The (\S+) tool completed in (\d+)ms with outcome (.+)$/);
174
+ if (toolMatch) {
175
+ const outcome = toolMatch[3];
176
+ let success = false;
177
+ let summary = '';
178
+ try {
179
+ const parsed = JSON.parse(outcome);
180
+ success = parsed.isSuccessful === true;
181
+ summary = parsed.forLlm ?? '';
182
+ }
183
+ catch {
184
+ summary = outcome;
185
+ }
186
+ steps.push({
187
+ time,
188
+ text: `${toolMatch[1]} (${toolMatch[2]}ms) ${success ? '&#10003;' : '&#10007;'} ${esc(summary)}`,
189
+ type: 'result',
190
+ });
191
+ }
192
+ }
193
+ else if (msg.startsWith('Transitioned flow state from ')) {
194
+ const stateMatch = msg.match(/Transitioned flow state from (\S+) to (\S+)/);
195
+ if (stateMatch) {
196
+ steps.push({
197
+ time,
198
+ text: `${stateMatch[1]} &rarr; ${stateMatch[2]}`,
199
+ type: 'state',
200
+ });
201
+ }
202
+ }
203
+ else if (msg.startsWith('Completed flow with state:')) {
204
+ steps.push({ time, text: msg, type: 'state' });
205
+ }
206
+ else if (msg.includes('Persisted Donobu triage') ||
207
+ msg.includes('Set up DONOBU client')) {
208
+ steps.push({ time, text: msg, type: 'info' });
209
+ }
210
+ // Skip noisy plugin loading lines, etc.
211
+ }
212
+ return steps;
213
+ }
214
+ function extractTests(jsonData) {
215
+ const tests = [];
216
+ for (const suite of jsonData.suites ?? []) {
217
+ for (const spec of suite.specs ?? []) {
218
+ for (const test of spec.tests ?? []) {
219
+ const annotations = test.annotations ?? [];
220
+ const hasHealAnnotation = annotations.some((a) => a.type === 'self-healed');
221
+ const isSelfHealed = hasHealAnnotation || test.donobuStatus === 'healed';
222
+ let status;
223
+ const lastResult = test.results?.at(-1);
224
+ if (test.status === 'skipped' ||
225
+ (!lastResult && test.status === undefined)) {
226
+ status = 'skipped';
227
+ }
228
+ else if (isSelfHealed) {
229
+ status = 'healed';
230
+ }
231
+ else {
232
+ status = lastResult?.status ?? 'unknown';
233
+ }
234
+ const objectiveAnnotation = annotations.find((a) => a.type === 'objective');
235
+ const results = (test.results ?? []).map((r, i) => {
236
+ const attachments = r.attachments ?? [];
237
+ // Parse step screenshot data from donobu-step-summary attachment
238
+ let stepScreenshots = [];
239
+ const summaryAtt = attachments.find((a) => a.name === 'donobu-step-summary');
240
+ if (summaryAtt?.body) {
241
+ try {
242
+ const raw = summaryAtt.body;
243
+ const decoded = Buffer.from(raw, 'base64').toString('utf8');
244
+ const parsed = JSON.parse(decoded);
245
+ stepScreenshots = parsed.map((s) => {
246
+ // Find the corresponding screenshot attachment
247
+ const imgAtt = attachments.find((a) => a.name === `donobu-step-${s.index}-${s.toolName}`);
248
+ return {
249
+ index: s.index,
250
+ toolName: s.toolName,
251
+ page: s.page,
252
+ startedAt: s.startedAt,
253
+ completedAt: s.completedAt,
254
+ success: s.success,
255
+ summary: s.summary,
256
+ imagePath: imgAtt?.path ?? null,
257
+ imageBody: imgAtt?.body ?? null,
258
+ imageContentType: imgAtt?.contentType ?? null,
259
+ };
260
+ });
261
+ }
262
+ catch {
263
+ // Ignore parse failures
264
+ }
265
+ }
266
+ return {
267
+ index: i,
268
+ status: r.status,
269
+ duration: r.duration ?? 0,
270
+ retry: r.retry ?? 0,
271
+ startTime: r.startTime ?? null,
272
+ errors: (r.errors ?? (r.error ? [r.error] : [])).map((e) => ({
273
+ message: e.message,
274
+ stack: e.stack,
275
+ snippet: e.snippet ? stripAnsi(e.snippet) : undefined,
276
+ actual: e.actual,
277
+ expected: e.expected,
278
+ })),
279
+ attachments,
280
+ steps: parseStderrSteps(r.stderr ?? []),
281
+ stepScreenshots,
282
+ };
283
+ });
284
+ tests.push({
285
+ file: suite.file,
286
+ specTitle: spec.title,
287
+ status,
288
+ isSelfHealed,
289
+ objective: objectiveAnnotation?.description ?? null,
290
+ annotations,
291
+ results,
292
+ projectName: test.projectName ?? '',
293
+ plan: null,
294
+ evidence: null,
295
+ });
296
+ }
297
+ }
298
+ }
299
+ return tests;
300
+ }
301
+ // ---------------------------------------------------------------------------
302
+ // HTML generation
303
+ // ---------------------------------------------------------------------------
304
+ const STATUS_CFG = {
305
+ passed: {
306
+ label: 'Passed',
307
+ color: '#10b981',
308
+ bg: 'rgba(16,185,129,0.08)',
309
+ icon: '&#10003;',
310
+ },
311
+ failed: {
312
+ label: 'Failed',
313
+ color: '#ef4444',
314
+ bg: 'rgba(239,68,68,0.08)',
315
+ icon: '&#10007;',
316
+ },
317
+ healed: {
318
+ label: 'Healed',
319
+ color: '#8b5cf6',
320
+ bg: 'rgba(139,92,246,0.08)',
321
+ icon: '&#9829;',
322
+ },
323
+ timedOut: {
324
+ label: 'Timed Out',
325
+ color: '#f59e0b',
326
+ bg: 'rgba(245,158,11,0.08)',
327
+ icon: '&#9202;',
328
+ },
329
+ skipped: {
330
+ label: 'Skipped',
331
+ color: '#6b7280',
332
+ bg: 'rgba(107,114,128,0.08)',
333
+ icon: '&#9654;',
334
+ },
335
+ interrupted: {
336
+ label: 'Interrupted',
337
+ color: '#f97316',
338
+ bg: 'rgba(249,115,22,0.08)',
339
+ icon: '&#9889;',
340
+ },
341
+ unknown: {
342
+ label: 'Unknown',
343
+ color: '#6b7280',
344
+ bg: 'rgba(107,114,128,0.08)',
345
+ icon: '?',
346
+ },
347
+ };
348
+ function cfg(status) {
349
+ return STATUS_CFG[status] ?? STATUS_CFG['unknown'];
350
+ }
351
+ const REASON_LABELS = {
352
+ SELECTOR_REGRESSION: { label: 'Selector Regression', color: '#f97316' },
353
+ TIMING_OR_SYNCHRONISATION: { label: 'Timing Issue', color: '#f59e0b' },
354
+ ASSERTION_DRIFT: { label: 'Assertion Drift', color: '#eab308' },
355
+ APPLICATION_DEFECT: { label: 'App Defect', color: '#ef4444' },
356
+ AUTOMATION_SCRIPT_ISSUE: { label: 'Script Issue', color: '#f97316' },
357
+ AUTHENTICATION_FAILURE: { label: 'Auth Failure', color: '#ec4899' },
358
+ ENVIRONMENT_CONFIGURATION: { label: 'Env Config', color: '#6366f1' },
359
+ TEST_DATA_UNAVAILABLE: { label: 'Test Data', color: '#8b5cf6' },
360
+ NETWORK_OR_DEPENDENCY: { label: 'Network/Deps', color: '#06b6d4' },
361
+ UNKNOWN: { label: 'Unknown', color: '#6b7280' },
362
+ };
363
+ function reasonCfg(reason) {
364
+ return REASON_LABELS[reason] ?? REASON_LABELS['UNKNOWN'];
365
+ }
366
+ function renderAttachments(attachments, outputDir) {
367
+ const rendered = [];
368
+ for (const att of attachments) {
369
+ if (!att.path && !att.body) {
370
+ continue;
371
+ }
372
+ if (att.contentType === 'application/json') {
373
+ continue;
374
+ }
375
+ // Step screenshots are rendered in the filmstrip, not here
376
+ if (att.name.startsWith('donobu-step-')) {
377
+ continue;
378
+ }
379
+ const isImage = att.contentType?.startsWith('image/');
380
+ const isVideo = att.contentType?.startsWith('video/');
381
+ if (att.path) {
382
+ // Check if file actually exists before rendering
383
+ const absPath = (0, path_1.resolve)(att.path);
384
+ const fileExists = (0, fs_1.existsSync)(absPath);
385
+ if (!fileExists) {
386
+ rendered.push(`<span class="attachment-missing">${esc(att.name)} (file not available)</span>`);
387
+ continue;
388
+ }
389
+ const assetHref = outputDir ? (0, path_1.relative)(outputDir, att.path) : att.path;
390
+ if (isImage) {
391
+ const imgLabel = att.name === 'screenshot'
392
+ ? 'Screenshot at test completion'
393
+ : att.name;
394
+ rendered.push(`<div class="img-wrapper"><a href="${esc(assetHref)}" target="_blank" class="attachment-link img-link" title="${esc(imgLabel)}"><img src="${esc(assetHref)}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /></a><span class="img-label">${esc(imgLabel)}</span></div>`);
395
+ }
396
+ else if (isVideo) {
397
+ rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link video-link" title="${esc(att.name)}">&#9654; ${esc(att.name)}</a>`);
398
+ }
399
+ else if (att.contentType === 'text/markdown') {
400
+ // Read and render markdown content inline (e.g., error-context.md page snapshots)
401
+ try {
402
+ const mdContent = (0, fs_1.readFileSync)(absPath, 'utf8');
403
+ rendered.push(`<details class="page-snapshot"><summary>${esc(att.name)}</summary><pre class="snapshot-block">${esc(mdContent)}</pre></details>`);
404
+ }
405
+ catch {
406
+ rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link" title="${esc(att.name)}">${esc(att.name)}</a>`);
407
+ }
408
+ }
409
+ else {
410
+ rendered.push(`<a href="${esc(assetHref)}" target="_blank" class="attachment-link" title="${esc(att.name)}">${esc(att.name)}</a>`);
411
+ }
412
+ }
413
+ else if (att.body && isImage) {
414
+ rendered.push(`<img src="data:${att.contentType};base64,${att.body}" alt="${esc(att.name)}" loading="lazy" class="screenshot" />`);
415
+ }
416
+ }
417
+ if (!rendered.length) {
418
+ return '';
419
+ }
420
+ // Separate images from other attachments for better layout
421
+ const images = [];
422
+ const others = [];
423
+ for (const r of rendered) {
424
+ if (r.includes('class="screenshot"')) {
425
+ images.push(r);
426
+ }
427
+ else {
428
+ others.push(r);
429
+ }
430
+ }
431
+ let html = '<div class="attachments-group">';
432
+ if (images.length) {
433
+ html += `<div class="attachments-images">${images.join('')}</div>`;
434
+ }
435
+ if (others.length) {
436
+ html += `<div class="attachments-meta">${others.join('')}</div>`;
437
+ }
438
+ html += '</div>';
439
+ return html;
440
+ }
441
+ function renderErrors(errors) {
442
+ if (!errors.length) {
443
+ return '';
444
+ }
445
+ let html = '';
446
+ for (const err of errors) {
447
+ if (err.message) {
448
+ html += `<pre class="error-block">${esc(stripAnsi(err.message))}</pre>`;
449
+ }
450
+ if (err.snippet) {
451
+ html += `<div class="detail-label">Code Snippet</div><pre class="snippet-block">${esc(err.snippet)}</pre>`;
452
+ }
453
+ if (err.actual !== null &&
454
+ err.actual !== undefined &&
455
+ err.expected !== null &&
456
+ err.expected !== undefined) {
457
+ html += `<div class="expect-actual"><span class="expect-label">Expected:</span> <code>${esc(err.expected)}</code><br/><span class="expect-label">Actual:</span> <code>${esc(err.actual)}</code></div>`;
458
+ }
459
+ if (err.stack && err.stack !== err.message) {
460
+ html += `<details class="stack-details"><summary>Stack trace</summary><pre class="stack-block">${esc(stripAnsi(err.stack))}</pre></details>`;
461
+ }
462
+ }
463
+ return html;
464
+ }
465
+ function renderSteps(steps, stepScreenshots, outputDir) {
466
+ const hasSteps = steps.filter((s) => s.type === 'action' || s.type === 'result').length > 0;
467
+ const hasScreenshots = stepScreenshots.length > 0;
468
+ if (!hasSteps && !hasScreenshots) {
469
+ return '';
470
+ }
471
+ const meaningful = steps.filter((s) => s.type === 'action' || s.type === 'result');
472
+ const totalItems = meaningful.length + stepScreenshots.length;
473
+ let html = '<details class="steps-section"><summary>Steps (' +
474
+ totalItems +
475
+ ')</summary>';
476
+ // Render AI agent tool call steps with optional screenshots
477
+ if (hasScreenshots) {
478
+ html += '<div class="step-filmstrip">';
479
+ for (const ss of stepScreenshots) {
480
+ const duration = ss.completedAt - ss.startedAt;
481
+ const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
482
+ const statusIcon = ss.success
483
+ ? '<span class="step-status-ok">&#10003;</span>'
484
+ : '<span class="step-status-fail">&#10007;</span>';
485
+ const stepId = `step-img-${uid()}`;
486
+ const hasImg = !!(ss.imagePath || ss.imageBody);
487
+ html += `<div class="filmstrip-step">`;
488
+ html += `<div class="filmstrip-header">`;
489
+ html += `${statusIcon}`;
490
+ html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
491
+ html += `<span class="filmstrip-duration">${durationStr}</span>`;
492
+ if (hasImg) {
493
+ html += `<button class="filmstrip-toggle" onclick="event.stopPropagation();var el=document.getElementById('${stepId}');el.classList.toggle('open');this.textContent=el.classList.contains('open')?'Hide':'Screenshot'">Screenshot</button>`;
494
+ }
495
+ html += `</div>`;
496
+ // Summary line
497
+ if (ss.summary) {
498
+ const shortSummary = ss.summary.length > 120
499
+ ? ss.summary.slice(0, 120) + '...'
500
+ : ss.summary;
501
+ html += `<div class="filmstrip-summary">${esc(shortSummary)}</div>`;
502
+ }
503
+ // Expandable screenshot (hidden by default)
504
+ let imgSrc = null;
505
+ if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
506
+ imgSrc = outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
507
+ }
508
+ else if (ss.imageBody && ss.imageContentType) {
509
+ imgSrc = `data:${ss.imageContentType};base64,${ss.imageBody}`;
510
+ }
511
+ if (imgSrc) {
512
+ html += `<div class="filmstrip-img" id="${stepId}">`;
513
+ html += `<img src="${esc(imgSrc)}" alt="Step ${ss.index}: ${esc(ss.toolName)}" loading="lazy" class="step-screenshot" />`;
514
+ html += `</div>`;
515
+ }
516
+ html += `</div>`;
517
+ }
518
+ html += '</div>';
519
+ }
520
+ // Render log-based steps (stderr parsing)
521
+ if (meaningful.length > 0) {
522
+ html += '<div class="steps-list">';
523
+ for (const step of meaningful) {
524
+ const typeClass = step.type === 'action' ? 'step-action' : 'step-result';
525
+ html += `<div class="step-entry ${typeClass}">`;
526
+ html += `<span class="step-time">${esc(step.time)}</span>`;
527
+ html += `<span class="step-text">${step.text}</span>`;
528
+ html += '</div>';
529
+ }
530
+ html += '</div>';
531
+ }
532
+ html += '</details>';
533
+ return html;
534
+ }
535
+ function renderFailureSummary(plan) {
536
+ const p = plan.plan;
537
+ const rc = reasonCfg(p.failureReason);
538
+ return `
539
+ <div class="failure-summary-banner">
540
+ <div class="failure-summary-header">
541
+ <span class="failure-reason-badge" style="background:${rc.color}">${esc(rc.label)}</span>
542
+ <span class="failure-confidence">${fmtPercent(p.confidence)} confidence</span>
543
+ <span class="source-hint">(AI analysis)</span>
544
+ </div>
545
+ <div class="failure-summary-text">${esc(p.failureSummary)}</div>
546
+ </div>`;
547
+ }
548
+ function renderTriageCard(plan) {
549
+ const p = plan.plan;
550
+ let flags = '';
551
+ if (p.shouldRetryAutomation) {
552
+ flags += '<span class="triage-flag retry">Retryable</span>';
553
+ }
554
+ if (p.requiresCodeChange) {
555
+ flags += '<span class="triage-flag code">Needs Code Change</span>';
556
+ }
557
+ if (p.requiresProductFix) {
558
+ flags += '<span class="triage-flag product">Needs Product Fix</span>';
559
+ }
560
+ let indicators = '';
561
+ if (p.observedIndicators?.length) {
562
+ indicators =
563
+ '<div class="triage-indicators">' +
564
+ p.observedIndicators
565
+ .map((i) => `<span class="indicator-tag">${esc(i)}</span>`)
566
+ .join('') +
567
+ '</div>';
568
+ }
569
+ let steps = '';
570
+ if (p.remediationSteps?.length) {
571
+ steps =
572
+ '<div class="remediation-steps"><div class="detail-label">Remediation Steps</div><ol>';
573
+ for (const step of p.remediationSteps) {
574
+ steps += `<li><span class="step-category">${esc(step.category)}</span> <strong>${esc(step.summary)}</strong><br/><span class="step-details">${esc(step.details)}</span></li>`;
575
+ }
576
+ steps += '</ol></div>';
577
+ }
578
+ let notes = '';
579
+ if (p.notes) {
580
+ notes = `<div class="triage-notes"><div class="detail-label">Notes</div><p>${esc(p.notes)}</p></div>`;
581
+ }
582
+ return `
583
+ <details class="triage-card">
584
+ <summary>Triage Details ${flags}</summary>
585
+ <div class="triage-card-body">
586
+ ${indicators}
587
+ ${steps}
588
+ ${notes}
589
+ </div>
590
+ </details>`;
591
+ }
592
+ function renderQuickActions(test) {
593
+ const cmds = [];
594
+ const title = test.specTitle;
595
+ const project = test.projectName;
596
+ const file = test.file;
597
+ // Re-run this specific test
598
+ const grepArg = title.includes("'") ? `"${title}"` : `'${title}'`;
599
+ const projectArg = project ? ` --project=${project}` : '';
600
+ cmds.push({
601
+ label: 'Re-run this test',
602
+ cmd: `npx donobu test -g ${grepArg}${projectArg}`,
603
+ });
604
+ // Only show treatment-plan actions for tests that are still failing
605
+ // (not for healed tests where the plan was already applied successfully)
606
+ if (test.plan && !test.isSelfHealed) {
607
+ const plan = test.plan.plan;
608
+ const directives = plan.automationDirectives;
609
+ if (directives?.clearPageAiCache) {
610
+ cmds.push({
611
+ label: 'Re-run with fresh cache',
612
+ cmd: `npx donobu test --clear-ai-cache -g ${grepArg}${projectArg}`,
613
+ });
614
+ }
615
+ // Only offer "apply treatment plan" if the plan says retry is viable
616
+ if (plan.shouldRetryAutomation) {
617
+ const planPath = test.plan.failure.runDirectory && test.plan.failure.evidencePath
618
+ ? test.plan.failure.evidencePath.replace(/failure-evidence-/, 'treatment-plan-')
619
+ : null;
620
+ if (planPath) {
621
+ cmds.push({
622
+ label: 'Apply treatment plan',
623
+ cmd: `npx donobu heal --plan ${planPath}`,
624
+ });
625
+ }
626
+ }
627
+ }
628
+ // Source file link
629
+ const sourceHtml = file
630
+ ? `<div class="qa-source"><span class="qa-source-label">Source:</span> <code>${esc(file)}</code></div>`
631
+ : '';
632
+ let html = '<div class="quick-actions">';
633
+ html += '<div class="qa-header">Quick Actions</div>';
634
+ for (const { label, cmd } of cmds) {
635
+ const cmdId = `cmd-${uid()}`;
636
+ html += `<div class="qa-row">`;
637
+ html += `<span class="qa-label">${esc(label)}</span>`;
638
+ html += `<div class="qa-cmd-wrapper">`;
639
+ html += `<code class="qa-cmd" id="${cmdId}">${esc(cmd)}</code>`;
640
+ html += `<button class="qa-copy" onclick="navigator.clipboard.writeText(document.getElementById('${cmdId}').textContent);this.textContent='Copied!';setTimeout(()=>this.textContent='Copy',1500)" title="Copy to clipboard">Copy</button>`;
641
+ html += `</div></div>`;
642
+ }
643
+ html += sourceHtml;
644
+ html += '</div>';
645
+ return html;
646
+ }
647
+ function renderEvidenceDeepDive(evidence) {
648
+ const ctx = evidence.failureContext;
649
+ const h = ctx.heuristics;
650
+ const id = uid();
651
+ let content = '';
652
+ if (h) {
653
+ const staleCacheEntries = Object.entries(h.staleCacheIndicators ?? {});
654
+ const activeIndicators = staleCacheEntries.filter(([, v]) => v);
655
+ content += '<div class="evidence-section">';
656
+ content +=
657
+ '<div class="detail-label">Heuristic Assessment <span class="source-hint">(fast, rule-based — may differ from AI analysis above)</span></div>';
658
+ content += '<div class="heuristic-grid">';
659
+ content += `<div class="h-item"><span class="h-label">Reason</span><span class="h-value">${esc(h.failureReason)}</span></div>`;
660
+ content += `<div class="h-item"><span class="h-label">Confidence</span><span class="h-value">${fmtPercent(h.confidence)}</span></div>`;
661
+ content += `<div class="h-item"><span class="h-label">During page.ai</span><span class="h-value">${h.occurredDuringPageAi ? 'Yes' : 'No'}</span></div>`;
662
+ if (activeIndicators.length > 0) {
663
+ content += `<div class="h-item full-width"><span class="h-label">Stale Cache Indicators</span><span class="h-value">${activeIndicators.map(([k]) => `<span class="indicator-tag warn">${esc(k)}</span>`).join('')}</span></div>`;
664
+ }
665
+ content += '</div>';
666
+ if (h.evidence?.length) {
667
+ content += '<div class="h-evidence">';
668
+ for (const e of h.evidence) {
669
+ content += `<div class="h-evidence-item">${esc(e)}</div>`;
670
+ }
671
+ content += '</div>';
672
+ }
673
+ content += '</div>';
674
+ }
675
+ const toolCalls = ctx.donobuFlow?.recentToolCalls;
676
+ if (toolCalls?.length) {
677
+ content += '<div class="evidence-section">';
678
+ content += '<div class="detail-label">Recent Donobu Tool Calls</div>';
679
+ content += '<div class="tool-calls">';
680
+ for (const tc of toolCalls) {
681
+ const name = tc.toolName ?? tc.name ?? 'unknown';
682
+ const dur = tc.durationMs !== null && tc.durationMs !== undefined
683
+ ? ` (${fmtDuration(tc.durationMs)})`
684
+ : '';
685
+ const success = tc.outcome?.isSuccessful;
686
+ const statusIcon = success === true
687
+ ? '<span class="tc-ok">&#10003;</span>'
688
+ : success === false
689
+ ? '<span class="tc-fail">&#10007;</span>'
690
+ : '';
691
+ content += `<div class="tool-call">${statusIcon}<span class="tc-name">${esc(name)}</span>${dur}</div>`;
692
+ }
693
+ content += '</div></div>';
694
+ }
695
+ if (ctx.testSnippet) {
696
+ content += '<div class="evidence-section">';
697
+ content += '<div class="detail-label">Test Source</div>';
698
+ content += `<pre class="snippet-block">${esc(ctx.testSnippet)}</pre>`;
699
+ content += '</div>';
700
+ }
701
+ if (!content) {
702
+ return '';
703
+ }
704
+ return `
705
+ <details class="evidence-deep-dive" id="ev-${id}">
706
+ <summary>Evidence Deep-Dive</summary>
707
+ ${content}
708
+ </details>`;
709
+ }
710
+ function renderResultTimeline(results, outputDir) {
711
+ if (results.length === 0) {
712
+ return '';
713
+ }
714
+ let html = '<div class="result-timeline">';
715
+ for (let i = 0; i < results.length; i++) {
716
+ const r = results[i];
717
+ const sc = cfg(r.status);
718
+ const label = i === 0 && results.length > 1
719
+ ? 'Initial Run'
720
+ : i === results.length - 1 && results.length > 1
721
+ ? 'Heal Run'
722
+ : `Run ${i + 1}`;
723
+ const isLast = i === results.length - 1;
724
+ html += `<div class="timeline-entry ${isLast ? 'last' : ''}">`;
725
+ html += `<div class="timeline-marker" style="background:${sc.color}"></div>`;
726
+ html += '<div class="timeline-content">';
727
+ html += '<div class="timeline-header">';
728
+ html += `<span class="timeline-label">${label}</span>`;
729
+ html += `<span class="timeline-status" style="color:${sc.color}">${sc.label}</span>`;
730
+ html += `<span class="timeline-duration">${fmtDuration(r.duration)}</span>`;
731
+ html += '</div>';
732
+ if (r.errors.length) {
733
+ html += `<div class="timeline-errors">${renderErrors(r.errors)}</div>`;
734
+ }
735
+ html += renderAttachments(r.attachments, outputDir);
736
+ html += renderSteps(r.steps, r.stepScreenshots, outputDir);
737
+ html += '</div></div>';
738
+ }
739
+ html += '</div>';
740
+ return html;
741
+ }
742
+ function generateHtml(jsonData, triage, outputDir) {
743
+ const tests = extractTests(jsonData);
744
+ const isMergedReport = !!jsonData.metadata?.donobuMergedReport;
745
+ const healedTestNames = jsonData.metadata?.donobuHealedTests ?? [];
746
+ // Match triage data to tests
747
+ const { plansByKey, evidenceByKey } = buildTriageLookups(triage);
748
+ for (const test of tests) {
749
+ const key = triageKey(test.file, test.projectName, test.specTitle);
750
+ test.plan = plansByKey.get(key) ?? null;
751
+ test.evidence = evidenceByKey.get(key) ?? null;
752
+ }
753
+ // Counts
754
+ const counts = {
755
+ passed: 0,
756
+ failed: 0,
757
+ healed: 0,
758
+ timedOut: 0,
759
+ skipped: 0,
760
+ interrupted: 0,
761
+ };
762
+ for (const t of tests) {
763
+ counts[t.status] = (counts[t.status] ?? 0) + 1;
764
+ }
765
+ const total = tests.length;
766
+ const totalDuration = tests.reduce((s, t) => s + t.results.reduce((rs, r) => rs + r.duration, 0), 0);
767
+ const allPassed = !counts['failed'] && !counts['timedOut'] && !counts['interrupted'];
768
+ // Group by file
769
+ const fileGroups = new Map();
770
+ for (const test of tests) {
771
+ const group = fileGroups.get(test.file) ?? [];
772
+ group.push(test);
773
+ fileGroups.set(test.file, group);
774
+ }
775
+ // Aggregate triage root causes
776
+ const reasonCounts = new Map();
777
+ for (const t of tests) {
778
+ if (t.plan) {
779
+ const r = t.plan.plan.failureReason;
780
+ reasonCounts.set(r, (reasonCounts.get(r) ?? 0) + 1);
781
+ }
782
+ }
783
+ // --- Build HTML sections ---
784
+ const statCardEntries = [
785
+ { key: 'passed', label: 'Passed' },
786
+ { key: 'healed', label: 'Healed' },
787
+ { key: 'failed', label: 'Failed' },
788
+ { key: 'timedOut', label: 'Timed Out' },
789
+ { key: 'skipped', label: 'Skipped' },
790
+ { key: 'interrupted', label: 'Interrupted' },
791
+ ];
792
+ let statCardsHtml = '';
793
+ for (const card of statCardEntries) {
794
+ const count = counts[card.key] ?? 0;
795
+ if (count === 0 && card.key !== 'passed' && card.key !== 'failed') {
796
+ continue;
797
+ }
798
+ const sc = cfg(card.key);
799
+ statCardsHtml += `<button class="stat-card" data-filter="${card.key}" onclick="filterByStatus('${card.key}')" title="Click to filter"><div class="stat-count" style="color:${sc.color}">${count}</div><div class="stat-label">${card.label}</div></button>`;
800
+ }
801
+ // Triage summary bar
802
+ let triageSummaryHtml = '';
803
+ if (reasonCounts.size > 0) {
804
+ let badges = '';
805
+ reasonCounts.forEach((count, reason) => {
806
+ const rc = reasonCfg(reason);
807
+ badges += `<span class="reason-summary-badge" style="background:${rc.color}">${count} ${esc(rc.label)}</span>`;
808
+ });
809
+ triageSummaryHtml = `<div class="triage-summary-bar"><span class="triage-summary-title">Root Causes</span>${badges}</div>`;
810
+ }
811
+ // File groups + test cards
812
+ let testSectionsHtml = '';
813
+ fileGroups.forEach((fileTests, file) => {
814
+ const fileCounts = {};
815
+ for (const t of fileTests) {
816
+ fileCounts[t.status] = (fileCounts[t.status] ?? 0) + 1;
817
+ }
818
+ const fileDuration = fileTests.reduce((s, t) => s + t.results.reduce((rs, r) => rs + r.duration, 0), 0);
819
+ const fileHasFailure = (fileCounts['failed'] ?? 0) > 0 ||
820
+ (fileCounts['timedOut'] ?? 0) > 0 ||
821
+ (fileCounts['interrupted'] ?? 0) > 0;
822
+ let fileBadgesHtml = '';
823
+ for (const key of Object.keys(STATUS_CFG)) {
824
+ const c = fileCounts[key] ?? 0;
825
+ if (c > 0) {
826
+ fileBadgesHtml += `<span class="file-badge" style="background:${cfg(key).color}">${c}</span>`;
827
+ }
828
+ }
829
+ let testsHtml = '';
830
+ for (const test of fileTests) {
831
+ const sc = cfg(test.status);
832
+ const testId = `t-${uid()}`;
833
+ const hasMultipleResults = test.results.length > 1;
834
+ const lastResult = test.results.at(-1);
835
+ let detailsHtml = '';
836
+ // 1. AI analysis — why it failed (the headline)
837
+ if (test.plan) {
838
+ detailsHtml += renderFailureSummary(test.plan);
839
+ }
840
+ // 2. Quick actions — what to do about it
841
+ if (test.status === 'failed' || test.status === 'healed') {
842
+ detailsHtml += renderQuickActions(test);
843
+ }
844
+ // 3. Errors + screenshots — raw evidence
845
+ if (hasMultipleResults) {
846
+ detailsHtml += renderResultTimeline(test.results, outputDir);
847
+ }
848
+ else if (lastResult) {
849
+ if (lastResult.errors.length) {
850
+ detailsHtml += `<div class="detail-section">${renderErrors(lastResult.errors)}</div>`;
851
+ }
852
+ detailsHtml += renderAttachments(lastResult.attachments, outputDir);
853
+ }
854
+ // 4. Self-healed banner
855
+ if (test.isSelfHealed) {
856
+ detailsHtml += `<div class="healed-banner"><span class="healed-icon">&#9829;</span> This test was automatically healed by re-running with Donobu treatment plan directives.</div>`;
857
+ }
858
+ // 5. Objective — what the test was trying to do
859
+ if (test.objective) {
860
+ detailsHtml += `<div class="detail-section"><div class="detail-label">Objective</div><div class="detail-objective">${esc(test.objective)}</div></div>`;
861
+ }
862
+ // 6. Steps — detailed forensics
863
+ if (!hasMultipleResults && lastResult) {
864
+ detailsHtml += renderSteps(lastResult.steps, lastResult.stepScreenshots, outputDir);
865
+ }
866
+ // 7. Triage details — remediation steps (expandable)
867
+ if (test.plan) {
868
+ detailsHtml += renderTriageCard(test.plan);
869
+ }
870
+ // 8. Evidence deep-dive — heuristics, tool calls (expandable)
871
+ if (test.evidence) {
872
+ detailsHtml += renderEvidenceDeepDive(test.evidence);
873
+ }
874
+ const hasDetails = detailsHtml.length > 0;
875
+ const expandableClass = hasDetails ? 'expandable' : '';
876
+ const chevron = hasDetails
877
+ ? '<span class="chevron">&#9656;</span>'
878
+ : '<span class="chevron-spacer"></span>';
879
+ const totalTestDuration = test.results.reduce((s, r) => s + r.duration, 0);
880
+ testsHtml += `
881
+ <div class="test-row ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" data-status="${test.status}" ${hasDetails ? `onclick="toggleDetail('${testId}',event)"` : ''}>
882
+ <div class="test-summary">
883
+ ${chevron}
884
+ <span class="status-dot" style="background:${sc.color}" title="${sc.label}"></span>
885
+ <span class="test-name">${esc(test.specTitle)}</span>
886
+ ${test.plan ? `<span class="inline-reason" style="color:${reasonCfg(test.plan.plan.failureReason).color}" title="${esc(test.plan.plan.failureReason)}">${esc(reasonCfg(test.plan.plan.failureReason).label)}</span>` : ''}
887
+ <span class="test-duration">${fmtDuration(totalTestDuration)}</span>
888
+ </div>
889
+ ${hasDetails ? `<div class="test-detail" id="${testId}">${detailsHtml}</div>` : ''}
890
+ </div>`;
891
+ }
892
+ testSectionsHtml += `
893
+ <div class="file-group ${fileHasFailure ? 'has-failure' : ''}" data-file="${esc(file)}">
894
+ <div class="file-header" onclick="toggleFileGroup(this)">
895
+ <span class="file-chevron">&#9662;</span>
896
+ <span class="file-name">${esc(file)}</span>
897
+ <div class="file-badges">${fileBadgesHtml}</div>
898
+ <span class="file-duration">${fmtDuration(fileDuration)}</span>
899
+ </div>
900
+ <div class="file-tests">${testsHtml}</div>
901
+ </div>`;
902
+ });
903
+ // Healed tests list
904
+ let healedListHtml = '';
905
+ if (healedTestNames.length > 0) {
906
+ let items = '';
907
+ for (const name of healedTestNames) {
908
+ items += `<li>${esc(name)}</li>`;
909
+ }
910
+ healedListHtml = `<div class="healed-summary-section"><h3>Auto-Healed Tests</h3><ul class="healed-list">${items}</ul></div>`;
911
+ }
912
+ const overallClass = allPassed ? 'overall-pass' : 'overall-fail';
913
+ const overallText = allPassed ? 'All Tests Passed' : 'Some Tests Failed';
914
+ const mergedBanner = isMergedReport
915
+ ? '<div class="merged-banner">Auto-heal summary generated by Donobu (merged initial and retry runs).</div>'
916
+ : '';
917
+ // -----------------------------------------------------------------------
918
+ // Full HTML document
919
+ // -----------------------------------------------------------------------
920
+ return `<!DOCTYPE html>
921
+ <html lang="en">
922
+ <head>
923
+ <meta charset="UTF-8">
924
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
925
+ <title>Donobu Test Report</title>
926
+ <style>
927
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
928
+ :root{
929
+ --bg:#0f1117;--surface:#161922;--surface-raised:#1e2130;
930
+ --border:#2a2d3a;--border-subtle:#222536;
931
+ --text:#e4e5ea;--text-muted:#8b8d98;--text-dim:#5f6170;
932
+ --green:#10b981;--red:#ef4444;--purple:#8b5cf6;
933
+ --amber:#f59e0b;--orange:#f97316;--gray:#6b7280;
934
+ --radius:8px;--radius-lg:12px;
935
+ --mono:'SF Mono','Fira Code','Fira Mono',Menlo,Consolas,monospace;
936
+ }
937
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;padding:0}
938
+ .container{max-width:1100px;margin:0 auto;padding:32px 24px}
939
+
940
+ /* Header */
941
+ .report-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
942
+ .report-title{font-size:22px;font-weight:700;display:flex;align-items:center;gap:10px}
943
+ .logo{width:28px;height:28px;border-radius:6px;background:linear-gradient(135deg,var(--purple),#6d28d9);display:flex;align-items:center;justify-content:center;font-size:15px;color:#fff;flex-shrink:0}
944
+ .report-meta{font-size:13px;color:var(--text-muted)}
945
+ .merged-banner{background:var(--surface-raised);border-left:3px solid var(--purple);padding:10px 14px;margin:16px 0;border-radius:0 var(--radius) var(--radius) 0;font-size:13px;color:var(--text-muted)}
946
+
947
+ /* Overall status */
948
+ .overall-status{text-align:center;padding:24px;border-radius:var(--radius-lg);margin:20px 0 24px;font-size:18px;font-weight:600}
949
+ .overall-pass{background:linear-gradient(135deg,rgba(16,185,129,.12),rgba(16,185,129,.04));border:1px solid rgba(16,185,129,.25);color:var(--green)}
950
+ .overall-fail{background:linear-gradient(135deg,rgba(239,68,68,.12),rgba(239,68,68,.04));border:1px solid rgba(239,68,68,.25);color:var(--red)}
951
+ .overall-status .overall-sub{font-size:13px;font-weight:400;color:var(--text-muted);margin-top:4px}
952
+
953
+ /* Stat cards */
954
+ .stat-cards{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap}
955
+ .stat-card{flex:1;min-width:90px;background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);padding:16px;text-align:center;cursor:pointer;transition:border-color .15s,background .15s;font-family:inherit;color:inherit}
956
+ .stat-card:hover{border-color:var(--border);background:var(--surface-raised)}
957
+ .stat-card.active-filter{border-color:var(--purple);background:rgba(139,92,246,.08)}
958
+ .stat-count{font-size:28px;font-weight:700;line-height:1.1}
959
+ .stat-label{font-size:12px;color:var(--text-muted);margin-top:4px;text-transform:uppercase;letter-spacing:.5px;font-weight:500}
960
+
961
+ /* Triage summary bar */
962
+ .triage-summary-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:12px 16px;background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:16px}
963
+ .triage-summary-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-right:4px}
964
+ .reason-summary-badge{font-size:12px;font-weight:600;color:#fff;padding:2px 10px;border-radius:10px}
965
+
966
+ /* Filter bar */
967
+ .filter-bar{display:none;align-items:center;gap:8px;margin-bottom:16px;padding:8px 12px;background:var(--surface);border-radius:var(--radius);font-size:13px;color:var(--text-muted)}
968
+ .filter-bar.visible{display:flex}
969
+ .clear-filter{margin-left:auto;background:none;border:1px solid var(--border);color:var(--text-muted);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:12px;font-family:inherit}
970
+ .clear-filter:hover{color:var(--text);border-color:var(--text-muted)}
971
+
972
+ /* File groups */
973
+ .file-group{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
974
+ .file-group.has-failure{border-color:rgba(239,68,68,.2)}
975
+ .file-group.hidden{display:none}
976
+ .file-header{display:flex;align-items:center;gap:10px;padding:14px 16px;cursor:pointer;user-select:none;transition:background .1s}
977
+ .file-header:hover{background:var(--surface-raised)}
978
+ .file-chevron{font-size:12px;color:var(--text-dim);transition:transform .15s;flex-shrink:0;width:14px;text-align:center}
979
+ .file-group.collapsed .file-chevron{transform:rotate(-90deg)}
980
+ .file-group.collapsed .file-tests{display:none}
981
+ .file-name{font-size:14px;font-weight:600;font-family:var(--mono)}
982
+ .file-badges{display:flex;gap:4px;margin-left:auto}
983
+ .file-badge{font-size:11px;font-weight:600;color:#fff;padding:1px 7px;border-radius:10px;min-width:22px;text-align:center}
984
+ .file-duration{font-size:12px;color:var(--text-dim);flex-shrink:0;min-width:50px;text-align:right}
985
+
986
+ /* Test rows */
987
+ .file-tests{border-top:1px solid var(--border-subtle)}
988
+ .test-row{border-bottom:1px solid var(--border-subtle)}
989
+ .test-row:last-child{border-bottom:none}
990
+ .test-row.hidden-by-filter{display:none}
991
+ .test-summary{display:flex;align-items:center;gap:10px;padding:10px 16px 10px 20px;transition:background .1s}
992
+ .test-row.expandable .test-summary{cursor:pointer}
993
+ .test-row.expandable .test-summary:hover{background:rgba(255,255,255,.02)}
994
+ .chevron{font-size:11px;color:var(--text-dim);transition:transform .15s;flex-shrink:0;width:12px}
995
+ .chevron-spacer{width:12px;flex-shrink:0}
996
+ .test-row.expanded .chevron{transform:rotate(90deg)}
997
+ .status-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
998
+ .test-name{font-size:13px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
999
+ .inline-reason{font-size:11px;font-weight:500;flex-shrink:0;padding:1px 8px;border-radius:4px;background:rgba(255,255,255,.04)}
1000
+ .test-duration{font-size:12px;color:var(--text-dim);flex-shrink:0;font-family:var(--mono)}
1001
+
1002
+ /* Test detail */
1003
+ .test-detail{padding:0 16px 14px 50px;display:none;flex-direction:column;gap:10px}
1004
+ .test-row.expanded .test-detail{display:flex}
1005
+ .detail-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:6px}
1006
+ .detail-objective{font-size:13px;padding:10px 14px;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border-subtle);white-space:pre-wrap;word-break:break-word}
1007
+
1008
+ /* Failure summary banner — the prominent "why did this fail?" block */
1009
+ .failure-summary-banner{background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.18);border-radius:var(--radius-lg);padding:14px 16px}
1010
+ .failure-summary-header{display:flex;align-items:center;gap:8px;margin-bottom:6px}
1011
+ .failure-reason-badge{font-size:12px;font-weight:700;color:#fff;padding:3px 10px;border-radius:6px}
1012
+ .failure-confidence{font-size:12px;color:var(--text-muted)}
1013
+ .source-hint{font-size:11px;color:var(--text-muted);font-weight:400;font-style:italic}
1014
+ .failure-summary-text{font-size:14px;color:var(--text);line-height:1.6}
1015
+
1016
+ /* Healed banner */
1017
+ .healed-banner{display:flex;align-items:center;gap:8px;background:rgba(139,92,246,.08);border:1px solid rgba(139,92,246,.2);border-radius:var(--radius);padding:10px 14px;font-size:13px;color:var(--purple)}
1018
+ .healed-icon{font-size:16px}
1019
+
1020
+ /* Error / snippet blocks */
1021
+ .error-block,.snippet-block,.stack-block,.snapshot-block{font-size:12px;font-family:var(--mono);padding:12px 14px;border-radius:var(--radius);overflow-x:auto;white-space:pre-wrap;word-break:break-word;line-height:1.6;max-height:400px;overflow-y:auto}
1022
+ .error-block{background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.15);color:#fca5a5}
1023
+ .snippet-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text)}
1024
+ .stack-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted);font-size:11px}
1025
+ .snapshot-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted);font-size:11px;max-height:300px}
1026
+ .stack-details,.page-snapshot{margin-top:6px}
1027
+ .stack-details summary,.page-snapshot summary{font-size:12px;color:var(--text-dim);cursor:pointer;user-select:none;padding:6px 0}
1028
+ .stack-details summary:hover,.page-snapshot summary:hover{color:var(--text-muted)}
1029
+ .expect-actual{font-size:13px;padding:8px 12px;background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);margin-top:6px}
1030
+ .expect-actual code{font-family:var(--mono);font-size:12px;color:var(--text)}
1031
+ .expect-label{color:var(--text-muted);font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.3px}
1032
+
1033
+ /* Steps */
1034
+ .steps-section{margin:8px 0;border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden}
1035
+ .steps-section summary{font-size:12px;font-weight:600;color:var(--text-muted);padding:8px 12px;cursor:pointer;user-select:none;background:var(--surface-raised)}
1036
+ .steps-section summary:hover{background:var(--surface)}
1037
+ .steps-section[open] summary{border-bottom:1px solid var(--border-subtle)}
1038
+ .steps-list{padding:4px 0}
1039
+ .step-entry{display:flex;gap:8px;padding:4px 12px;font-size:12px;align-items:baseline}
1040
+ .step-time{color:var(--text-dim);font-family:var(--mono);font-size:11px;flex-shrink:0;width:85px}
1041
+ .step-text{color:var(--text-muted);min-width:0}
1042
+ .step-action .step-text{color:var(--text);font-weight:500}
1043
+ .step-result .step-text{color:var(--text-muted)}
1044
+
1045
+ /* Step filmstrip — AI agent tool call screenshots */
1046
+ .step-filmstrip{display:flex;flex-direction:column}
1047
+ .filmstrip-step{border-bottom:1px solid var(--border-subtle);padding:6px 12px}
1048
+ .filmstrip-step:last-child{border-bottom:none}
1049
+ .filmstrip-header{display:flex;align-items:center;gap:8px;user-select:none}
1050
+ .filmstrip-tool{font-size:12px;font-weight:600;color:var(--text);font-family:var(--mono)}
1051
+ .filmstrip-duration{font-size:11px;color:var(--text-dim);font-family:var(--mono)}
1052
+ .filmstrip-toggle{margin-left:auto;font-size:11px;padding:2px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-card);color:var(--accent);cursor:pointer;transition:all .15s;font-family:var(--mono)}
1053
+ .filmstrip-toggle:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
1054
+ .filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-left:22px}
1055
+ .step-status-ok{color:var(--green);font-size:12px;font-weight:bold}
1056
+ .step-status-fail{color:var(--red);font-size:12px;font-weight:bold}
1057
+ .filmstrip-img{display:none;padding:8px 0 4px 22px}
1058
+ .filmstrip-img.open{display:block}
1059
+ .step-screenshot{max-width:100%;max-height:250px;border-radius:var(--radius);border:1px solid var(--border);cursor:pointer}
1060
+
1061
+ /* Result timeline */
1062
+ .result-timeline{position:relative;padding-left:20px}
1063
+ .timeline-entry{position:relative;padding-bottom:16px;padding-left:16px;border-left:2px solid var(--border)}
1064
+ .timeline-entry.last{border-left-color:transparent}
1065
+ .timeline-marker{position:absolute;left:-6px;top:4px;width:10px;height:10px;border-radius:50%;border:2px solid var(--surface)}
1066
+ .timeline-content{min-width:0}
1067
+ .timeline-header{display:flex;align-items:center;gap:10px;margin-bottom:6px}
1068
+ .timeline-label{font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px}
1069
+ .timeline-status{font-size:13px;font-weight:600}
1070
+ .timeline-duration{font-size:12px;color:var(--text-dim);font-family:var(--mono)}
1071
+ .timeline-errors{margin-bottom:8px}
1072
+
1073
+ /* Attachments */
1074
+ .attachments-group{display:flex;flex-direction:column;gap:8px}
1075
+ .attachments-images{display:flex;gap:8px;flex-wrap:wrap}
1076
+ .attachments-meta{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start}
1077
+ .img-wrapper{display:inline-flex;flex-direction:column;gap:4px}
1078
+ .img-label{font-size:11px;color:var(--text-dim);font-style:italic}
1079
+ .screenshot{max-width:100%;max-height:300px;border-radius:var(--radius);border:1px solid var(--border);cursor:pointer;transition:opacity .15s}
1080
+ .screenshot:hover{opacity:.9}
1081
+ .page-snapshot{width:100%}
1082
+ .img-link{display:block}
1083
+ .attachment-link{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--purple);text-decoration:none;padding:4px 10px;background:var(--surface-raised);border:1px solid var(--border-subtle);border-radius:var(--radius)}
1084
+ .attachment-link:hover{border-color:var(--purple);background:rgba(139,92,246,.06)}
1085
+ .video-link{color:var(--amber)}
1086
+ .video-link:hover{border-color:var(--amber)}
1087
+ .attachment-missing{font-size:12px;color:var(--text-dim);font-style:italic;padding:4px 10px;background:var(--surface-raised);border:1px solid var(--border-subtle);border-radius:var(--radius)}
1088
+
1089
+ /* Triage card (expandable) */
1090
+ .triage-card{margin:8px 0;border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
1091
+ .triage-card summary{font-size:12px;font-weight:600;color:var(--text-muted);padding:10px 14px;cursor:pointer;user-select:none;background:var(--bg);display:flex;align-items:center;gap:8px;flex-wrap:wrap}
1092
+ .triage-card summary:hover{background:var(--surface-raised)}
1093
+ .triage-card[open] summary{border-bottom:1px solid var(--border-subtle)}
1094
+ .triage-card-body{padding:12px 16px}
1095
+ .triage-flags{display:flex;gap:4px}
1096
+ .triage-flag{font-size:10px;font-weight:600;padding:2px 8px;border-radius:4px;text-transform:uppercase;letter-spacing:.3px}
1097
+ .triage-flag.retry{background:rgba(16,185,129,.1);color:var(--green);border:1px solid rgba(16,185,129,.2)}
1098
+ .triage-flag.code{background:rgba(245,158,11,.1);color:var(--amber);border:1px solid rgba(245,158,11,.2)}
1099
+ .triage-flag.product{background:rgba(239,68,68,.1);color:var(--red);border:1px solid rgba(239,68,68,.2)}
1100
+ .triage-indicators{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
1101
+ .indicator-tag{font-size:11px;padding:2px 8px;border-radius:4px;background:var(--surface-raised);color:var(--text-muted);border:1px solid var(--border-subtle)}
1102
+ .indicator-tag.warn{background:rgba(245,158,11,.08);color:var(--amber);border-color:rgba(245,158,11,.2)}
1103
+ .remediation-steps{margin-top:8px}
1104
+ .remediation-steps ol{padding-left:18px;font-size:13px;color:var(--text)}
1105
+ .remediation-steps li{margin-bottom:8px;line-height:1.5}
1106
+ .step-category{font-size:10px;font-weight:600;padding:1px 6px;border-radius:3px;background:var(--surface-raised);color:var(--text-muted);text-transform:uppercase;letter-spacing:.3px;vertical-align:middle}
1107
+ .step-details{font-size:12px;color:var(--text-muted);display:block;margin-top:2px}
1108
+ .triage-notes{margin-top:8px}
1109
+ .triage-notes p{font-size:13px;color:var(--text-muted)}
1110
+
1111
+ /* Evidence deep-dive */
1112
+ .evidence-deep-dive{margin:8px 0;border:1px solid var(--border-subtle);border-radius:var(--radius);overflow:hidden}
1113
+ .evidence-deep-dive summary{font-size:12px;font-weight:600;color:var(--text-muted);padding:10px 14px;cursor:pointer;user-select:none;background:var(--surface-raised);transition:background .1s}
1114
+ .evidence-deep-dive summary:hover{background:var(--surface)}
1115
+ .evidence-deep-dive[open] summary{border-bottom:1px solid var(--border-subtle)}
1116
+ .evidence-section{padding:12px 14px}
1117
+ .evidence-section+.evidence-section{border-top:1px solid var(--border-subtle)}
1118
+ .heuristic-grid{display:flex;flex-wrap:wrap;gap:8px}
1119
+ .h-item{padding:8px 10px;background:var(--bg);border-radius:var(--radius);border:1px solid var(--border-subtle);min-width:120px}
1120
+ .h-item.full-width{width:100%}
1121
+ .h-label{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);display:block;margin-bottom:2px}
1122
+ .h-value{font-size:13px;color:var(--text)}
1123
+ .h-evidence{margin-top:8px}
1124
+ .h-evidence-item{font-size:12px;color:var(--text-muted);padding:3px 0;padding-left:12px;position:relative}
1125
+ .h-evidence-item::before{content:'\\2022';position:absolute;left:0;color:var(--text-dim)}
1126
+ .tool-calls{display:flex;flex-direction:column;gap:3px}
1127
+ .tool-call{font-size:12px;font-family:var(--mono);color:var(--text-muted);padding:4px 8px;background:var(--bg);border-radius:4px;display:flex;align-items:center;gap:6px}
1128
+ .tc-name{color:var(--text)}
1129
+ .tc-ok{color:var(--green);font-weight:bold}
1130
+ .tc-fail{color:var(--red);font-weight:bold}
1131
+
1132
+ /* Healed summary */
1133
+ .healed-summary-section{background:var(--surface);border:1px solid rgba(139,92,246,.2);border-radius:var(--radius-lg);padding:18px 20px;margin-top:24px}
1134
+ .healed-summary-section h3{font-size:14px;font-weight:600;color:var(--purple);margin-bottom:10px}
1135
+ .healed-list{list-style:none}
1136
+ .healed-list li{font-size:13px;padding:4px 0 4px 18px;position:relative}
1137
+ .healed-list li::before{content:'\\2764';position:absolute;left:0;color:var(--purple);font-size:11px}
1138
+
1139
+ /* Quick Actions */
1140
+ .quick-actions{padding:12px;background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius)}
1141
+ .qa-header{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px}
1142
+ .qa-row{display:flex;align-items:center;gap:8px;margin-bottom:6px}
1143
+ .qa-label{font-size:12px;color:var(--text-muted);white-space:nowrap;min-width:140px}
1144
+ .qa-cmd-wrapper{display:flex;align-items:center;gap:6px;flex:1;min-width:0}
1145
+ .qa-cmd{font-size:12px;font-family:var(--mono);background:var(--bg-card);padding:5px 10px;border-radius:4px;border:1px solid var(--border-subtle);color:var(--text);white-space:nowrap;overflow-x:auto;flex:1;min-width:0}
1146
+ .qa-copy{font-size:11px;padding:3px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-card);color:var(--text-muted);cursor:pointer;white-space:nowrap;transition:all .15s}
1147
+ .qa-copy:hover{background:var(--accent);color:#fff;border-color:var(--accent)}
1148
+ .qa-source{font-size:12px;color:var(--text-dim);margin-top:6px;padding-top:6px;border-top:1px solid var(--border-subtle)}
1149
+ .qa-source-label{font-weight:600}
1150
+
1151
+ /* Footer */
1152
+ .report-footer{text-align:center;font-size:12px;color:var(--text-dim);margin-top:32px;padding-top:16px;border-top:1px solid var(--border-subtle)}
1153
+
1154
+ /* Lightbox */
1155
+ .lightbox{display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:1000;align-items:center;justify-content:center;cursor:zoom-out}
1156
+ .lightbox.open{display:flex}
1157
+ .lightbox img{max-width:95vw;max-height:95vh;border-radius:var(--radius)}
1158
+
1159
+ /* Print */
1160
+ @media print{
1161
+ body{background:#fff;color:#111}
1162
+ .stat-card,.file-group{border:1px solid #ddd}
1163
+ .test-detail{display:block!important}
1164
+ .screenshot{max-height:200px}
1165
+ .lightbox{display:none!important}
1166
+ }
1167
+ </style>
1168
+ </head>
1169
+ <body>
1170
+ <div class="container">
1171
+ <div class="report-header">
1172
+ <div class="report-title"><div class="logo">D</div>Donobu Test Report</div>
1173
+ <div class="report-meta">${total} test${total !== 1 ? 's' : ''} &middot; ${fmtDuration(totalDuration)}</div>
1174
+ </div>
1175
+
1176
+ ${mergedBanner}
1177
+
1178
+ <div class="overall-status ${overallClass}">
1179
+ ${overallText}
1180
+ <div class="overall-sub">${total} test${total !== 1 ? 's' : ''} across ${fileGroups.size} file${fileGroups.size !== 1 ? 's' : ''}</div>
1181
+ </div>
1182
+
1183
+ <div class="stat-cards">${statCardsHtml}</div>
1184
+
1185
+ ${triageSummaryHtml}
1186
+
1187
+ <div class="filter-bar" id="filterBar">
1188
+ <span>Showing: <strong id="filterLabel"></strong></span>
1189
+ <button class="clear-filter" onclick="clearFilter()">Clear filter</button>
1190
+ </div>
1191
+
1192
+ ${testSectionsHtml}
1193
+ ${healedListHtml}
1194
+
1195
+ <div class="report-footer">Report generated by Donobu</div>
1196
+ </div>
1197
+
1198
+ <div class="lightbox" id="lightbox" onclick="closeLightbox()">
1199
+ <img id="lightboxImg" src="" alt="Screenshot" />
1200
+ </div>
1201
+
1202
+ <script>
1203
+ function toggleDetail(id,e){if(e&&e.target.closest('.test-detail'))return;var el=document.getElementById(id);if(el){var row=el.closest('.test-row');if(row)row.classList.toggle('expanded')}}
1204
+ function toggleFileGroup(h){h.closest('.file-group').classList.toggle('collapsed')}
1205
+
1206
+ var activeFilter=null;
1207
+ function filterByStatus(s){
1208
+ if(activeFilter===s){clearFilter();return}
1209
+ activeFilter=s;
1210
+ document.querySelectorAll('.stat-card').forEach(function(c){c.classList.toggle('active-filter',c.getAttribute('data-filter')===s)});
1211
+ document.getElementById('filterBar').classList.add('visible');
1212
+ document.getElementById('filterLabel').textContent=s.charAt(0).toUpperCase()+s.slice(1);
1213
+ document.querySelectorAll('.test-row').forEach(function(r){r.classList.toggle('hidden-by-filter',r.getAttribute('data-status')!==s)});
1214
+ document.querySelectorAll('.file-group').forEach(function(g){
1215
+ var vis=g.querySelectorAll('.test-row:not(.hidden-by-filter)');
1216
+ g.classList.toggle('hidden',vis.length===0);
1217
+ g.classList.remove('collapsed');
1218
+ });
1219
+ }
1220
+ function clearFilter(){
1221
+ activeFilter=null;
1222
+ document.querySelectorAll('.stat-card').forEach(function(c){c.classList.remove('active-filter')});
1223
+ document.getElementById('filterBar').classList.remove('visible');
1224
+ document.querySelectorAll('.test-row').forEach(function(r){r.classList.remove('hidden-by-filter')});
1225
+ document.querySelectorAll('.file-group').forEach(function(g){g.classList.remove('hidden')});
1226
+ }
1227
+
1228
+ // Screenshot lightbox
1229
+ document.addEventListener('click',function(e){
1230
+ var img=e.target.closest('.screenshot');
1231
+ if(img){
1232
+ e.preventDefault();e.stopPropagation();
1233
+ var src=img.closest('a')?img.closest('a').href:img.src;
1234
+ document.getElementById('lightboxImg').src=src;
1235
+ document.getElementById('lightbox').classList.add('open');
1236
+ }
1237
+ });
1238
+ function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
1239
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
1240
+
1241
+ // Auto-expand failed/timedout/interrupted/healed tests
1242
+ document.querySelectorAll('.test-row.failed,.test-row.timedout,.test-row.interrupted,.test-row.healed').forEach(function(r){r.classList.add('expanded')});
1243
+ </script>
1244
+ </body>
1245
+ </html>`;
1246
+ }
1247
+ // ---------------------------------------------------------------------------
1248
+ // Main
1249
+ // ---------------------------------------------------------------------------
1250
+ try {
1251
+ const { jsonData, outputFile, triageDir } = parseArgs();
1252
+ // Auto-discover triage dir from merged report metadata if not explicitly given
1253
+ let resolvedTriageDir = triageDir;
1254
+ if (!resolvedTriageDir && jsonData.metadata?.sources?.initial) {
1255
+ const initialPath = jsonData.metadata.sources.initial;
1256
+ const candidate = (0, path_1.dirname)(initialPath);
1257
+ if ((0, fs_1.existsSync)(candidate) && (0, path_1.basename)(candidate) !== '.') {
1258
+ resolvedTriageDir = candidate;
1259
+ }
1260
+ }
1261
+ const triage = resolvedTriageDir
1262
+ ? loadTriageData(resolvedTriageDir)
1263
+ : { plans: [], evidence: [] };
1264
+ const outputDir = outputFile ? (0, path_1.dirname)((0, path_1.resolve)(outputFile)) : null;
1265
+ const html = generateHtml(jsonData, triage, outputDir);
1266
+ if (outputFile) {
1267
+ (0, fs_1.writeFileSync)(outputFile, html, 'utf8');
1268
+ console.error(`Report written to ${outputFile}`);
1269
+ }
1270
+ else {
1271
+ console.log(html);
1272
+ }
1273
+ }
1274
+ catch (error) {
1275
+ console.error('Error processing JSON:', error.message);
1276
+ process.exit(1);
1277
+ }
1278
+ //# sourceMappingURL=playwright-json-to-html.js.map