donobu 5.8.4 → 5.9.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.
@@ -15,6 +15,8 @@
15
15
  * ```
16
16
  */
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.loadTriageData = loadTriageData;
19
+ exports.generateHtml = generateHtml;
18
20
  const fs_1 = require("fs");
19
21
  const path_1 = require("path");
20
22
  // ---------------------------------------------------------------------------
@@ -23,6 +25,76 @@ const path_1 = require("path");
23
25
  function stripAnsi(str) {
24
26
  return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
25
27
  }
28
+ /**
29
+ * Convert ANSI SGR color codes to HTML spans. Handles the subset Playwright's
30
+ * expect formatter emits: red (31), green (32), dim (2), and resets (0/22/39).
31
+ * Text segments are HTML-escaped before wrapping.
32
+ */
33
+ function ansiToHtml(str) {
34
+ const colorMap = {
35
+ '31': 'var(--red)',
36
+ '32': 'var(--green)',
37
+ '2': 'var(--text-dim)',
38
+ };
39
+ const resetCodes = new Set(['0', '22', '39', '49', '']);
40
+ let html = '';
41
+ let openSpans = 0;
42
+ // Split on ESC [ ... m sequences; odd indices are the captured code group
43
+ const parts = str.split(/\x1b\[([0-9;]*)m/);
44
+ for (let i = 0; i < parts.length; i++) {
45
+ if (i % 2 === 0) {
46
+ html += esc(parts[i]);
47
+ }
48
+ else {
49
+ const code = parts[i];
50
+ if (resetCodes.has(code)) {
51
+ if (openSpans > 0) {
52
+ html += '</span>'.repeat(openSpans);
53
+ openSpans = 0;
54
+ }
55
+ }
56
+ else {
57
+ const color = colorMap[code];
58
+ if (color) {
59
+ html += `<span style="color:${color}">`;
60
+ openSpans++;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ if (openSpans > 0) {
66
+ html += '</span>'.repeat(openSpans);
67
+ }
68
+ return html;
69
+ }
70
+ /**
71
+ * Read a few lines of source around `targetLine` (1-based) and return an HTML
72
+ * snippet block, or null if the file is unreadable.
73
+ */
74
+ function readSourceSnippet(file, targetLine) {
75
+ try {
76
+ const source = (0, fs_1.readFileSync)(file, 'utf8');
77
+ const lines = source.split('\n');
78
+ const context = 2;
79
+ const start = Math.max(0, targetLine - 1 - context);
80
+ const end = Math.min(lines.length - 1, targetLine - 1 + context);
81
+ let html = '<div class="native-step-snippet">';
82
+ for (let i = start; i <= end; i++) {
83
+ const lineNum = i + 1;
84
+ const isTarget = lineNum === targetLine;
85
+ const marker = isTarget ? '&gt;' : '&nbsp;';
86
+ html += `<div class="snippet-line${isTarget ? ' snippet-line--target' : ''}">`;
87
+ html += `<span class="snippet-linenum">${marker} ${String(lineNum).padStart(3)}</span>`;
88
+ html += `<span class="snippet-code"> ${esc(lines[i] ?? '')}</span>`;
89
+ html += '</div>';
90
+ }
91
+ html += '</div>';
92
+ return html;
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ }
26
98
  function esc(str) {
27
99
  return str
28
100
  .replace(/&/g, '&amp;')
@@ -277,6 +349,18 @@ function extractTests(jsonData) {
277
349
  // Ignore parse failures
278
350
  }
279
351
  }
352
+ // Parse native Playwright steps from donobu-native-steps attachment
353
+ let nativeSteps = [];
354
+ const nativeAtt = attachments.find((a) => a.name === 'donobu-native-steps');
355
+ if (nativeAtt?.body) {
356
+ try {
357
+ const decoded = Buffer.from(nativeAtt.body, 'base64').toString('utf8');
358
+ nativeSteps = JSON.parse(decoded);
359
+ }
360
+ catch {
361
+ // Ignore parse failures
362
+ }
363
+ }
280
364
  return {
281
365
  index: i,
282
366
  status: r.status,
@@ -286,13 +370,21 @@ function extractTests(jsonData) {
286
370
  errors: (r.errors ?? (r.error ? [r.error] : [])).map((e) => ({
287
371
  message: e.message,
288
372
  stack: e.stack,
289
- snippet: e.snippet ? stripAnsi(e.snippet) : undefined,
373
+ snippet: e.snippet ?? undefined,
290
374
  actual: e.actual,
291
375
  expected: e.expected,
376
+ location: e.location
377
+ ? {
378
+ file: e.location.file ?? '',
379
+ line: e.location.line ?? 0,
380
+ column: e.location.column ?? 0,
381
+ }
382
+ : undefined,
292
383
  })),
293
384
  attachments,
294
385
  steps: parseStderrSteps(r.stderr ?? []),
295
386
  stepScreenshots,
387
+ nativeSteps,
296
388
  };
297
389
  });
298
390
  tests.push({
@@ -491,16 +583,14 @@ function renderErrors(errors) {
491
583
  let html = '';
492
584
  for (const err of errors) {
493
585
  if (err.message) {
494
- html += `<pre class="error-block">${esc(stripAnsi(err.message))}</pre>`;
586
+ // Use a neutral container so ANSI-derived colors (green Expected, red
587
+ // Received, dim call log) render correctly without a red tint fighting them.
588
+ html += `<pre class="error-block-ansi">${ansiToHtml(err.message)}</pre>`;
495
589
  }
496
590
  if (err.snippet) {
497
- html += `<div class="detail-label">Code Snippet</div><pre class="snippet-block">${esc(err.snippet)}</pre>`;
498
- }
499
- if (err.actual !== null &&
500
- err.actual !== undefined &&
501
- err.expected !== null &&
502
- err.expected !== undefined) {
503
- 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>`;
591
+ // Playwright's pre-formatted snippet (pipe/arrow markers) — convert ANSI
592
+ // highlight codes to HTML spans so the failing line shows in color.
593
+ html += `<pre class="snippet-block">${ansiToHtml(err.snippet)}</pre>`;
504
594
  }
505
595
  if (err.stack && err.stack !== err.message) {
506
596
  html += `<details class="stack-details"><summary>Stack trace</summary><pre class="stack-block">${esc(stripAnsi(err.stack))}</pre></details>`;
@@ -508,93 +598,142 @@ function renderErrors(errors) {
508
598
  }
509
599
  return html;
510
600
  }
511
- function renderSteps(steps, stepScreenshots, outputDir) {
601
+ function renderNativeStep(ns) {
602
+ const statusIcon = ns.passed
603
+ ? '<span class="step-status-ok">&#10003;</span>'
604
+ : '<span class="step-status-fail">&#10007;</span>';
605
+ const categoryBadge = `<span class="native-step-badge native-step-badge--${ns.category}">${esc(ns.category)}</span>`;
606
+ const locationStr = ns.location?.file
607
+ ? esc(`${ns.location.file.replace(/.*[/\\]/, '')}:${ns.location.line}`)
608
+ : '';
609
+ let html = `<div class="filmstrip-step native-step">`;
610
+ html += `<div class="filmstrip-header">`;
611
+ html += statusIcon;
612
+ html += `<span class="native-step-title">${esc(ns.title)}</span>`;
613
+ html += categoryBadge;
614
+ if (locationStr) {
615
+ html += `<span class="native-step-location">${locationStr}</span>`;
616
+ }
617
+ html += `</div>`;
618
+ if (!ns.passed && ns.error?.message) {
619
+ html += `<pre class="native-step-error">${ansiToHtml(ns.error.message)}</pre>`;
620
+ }
621
+ if (!ns.passed && ns.location?.file) {
622
+ const snippet = readSourceSnippet(ns.location.file, ns.location.line);
623
+ if (snippet) {
624
+ html += snippet;
625
+ }
626
+ }
627
+ html += `</div>`;
628
+ return html;
629
+ }
630
+ function renderFilmstripStep(ss, outputDir) {
631
+ const duration = ss.completedAt - ss.startedAt;
632
+ const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
633
+ const statusIcon = ss.success
634
+ ? '<span class="step-status-ok">&#10003;</span>'
635
+ : '<span class="step-status-fail">&#10007;</span>';
636
+ let imgSrc = null;
637
+ if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
638
+ imgSrc = outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
639
+ }
640
+ else if (ss.imageBody && ss.imageContentType) {
641
+ imgSrc = `data:${ss.imageContentType};base64,${ss.imageBody}`;
642
+ }
643
+ const hasDetail = !!(ss.parameters || ss.outcome);
644
+ let jsonBlock = '';
645
+ if (hasDetail) {
646
+ const jsonObj = {
647
+ toolName: ss.toolName,
648
+ page: ss.page,
649
+ };
650
+ if (ss.parameters) {
651
+ jsonObj.parameters = ss.parameters;
652
+ }
653
+ if (ss.outcome) {
654
+ jsonObj.outcome = ss.outcome;
655
+ }
656
+ jsonBlock = `<pre class="step-json">${esc(JSON.stringify(jsonObj, null, 2))}</pre>`;
657
+ }
658
+ const hasExpandable = !!(imgSrc || hasDetail);
659
+ const expandId = `step-expand-${uid()}`;
660
+ let html = `<div class="filmstrip-step${hasExpandable ? ' expandable' : ''}">`;
661
+ html += `<div class="filmstrip-header">`;
662
+ html += statusIcon;
663
+ html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
664
+ html += `<span class="filmstrip-duration">${durationStr}</span>`;
665
+ if (hasExpandable) {
666
+ html += `<span class="filmstrip-chevron">&#9656;</span>`;
667
+ }
668
+ html += `</div>`;
669
+ if (ss.summary) {
670
+ html += `<div class="filmstrip-summary">${esc(ss.summary)}</div>`;
671
+ }
672
+ if (hasExpandable) {
673
+ html += `<div class="filmstrip-detail" id="${expandId}">`;
674
+ if (imgSrc) {
675
+ html += `<a href="${esc(imgSrc)}" target="_blank"><img src="${esc(imgSrc)}" alt="Step ${ss.index}: ${esc(ss.toolName)}" loading="lazy" class="step-screenshot" /></a>`;
676
+ }
677
+ if (jsonBlock) {
678
+ html += jsonBlock;
679
+ }
680
+ html += `</div>`;
681
+ }
682
+ html += `</div>`;
683
+ return html;
684
+ }
685
+ function renderSteps(steps, stepScreenshots, nativeSteps, outputDir) {
512
686
  const meaningful = steps.filter((s) => s.type === 'action' || s.type === 'result');
513
687
  const hasScreenshots = stepScreenshots.length > 0;
514
- if (!meaningful.length && !hasScreenshots) {
688
+ const hasNative = nativeSteps.length > 0;
689
+ if (!meaningful.length && !hasScreenshots && !hasNative) {
515
690
  return '';
516
691
  }
517
- // When we have filmstrip data (tool calls from persistence with screenshots),
518
- // it supersedes the log-based steps which are the same events parsed from
519
- // stderr. Use filmstrip as the single timeline and skip duplicates.
520
- const stepCount = hasScreenshots ? stepScreenshots.length : meaningful.length;
521
- let html = '<details class="steps-section"><summary>Steps (' +
522
- stepCount +
523
- ')</summary>';
524
- if (hasScreenshots) {
692
+ if (hasScreenshots || hasNative) {
693
+ const timeline = [
694
+ ...stepScreenshots.map((ss) => ({
695
+ t: ss.completedAt,
696
+ kind: 'donobu',
697
+ ss,
698
+ })),
699
+ ...nativeSteps.map((ns) => ({
700
+ t: ns.endWallTime,
701
+ kind: 'native',
702
+ ns,
703
+ })),
704
+ ];
705
+ timeline.sort((a, b) => a.t - b.t);
706
+ const stepCount = timeline.length;
707
+ let html = '<details class="steps-section"><summary>Steps (' +
708
+ stepCount +
709
+ ')</summary>';
525
710
  html += '<div class="step-filmstrip">';
526
- for (const ss of stepScreenshots) {
527
- const duration = ss.completedAt - ss.startedAt;
528
- const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
529
- const statusIcon = ss.success
530
- ? '<span class="step-status-ok">&#10003;</span>'
531
- : '<span class="step-status-fail">&#10007;</span>';
532
- // Resolve screenshot src
533
- let imgSrc = null;
534
- if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
535
- imgSrc = outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
536
- }
537
- else if (ss.imageBody && ss.imageContentType) {
538
- imgSrc = `data:${ss.imageContentType};base64,${ss.imageBody}`;
539
- }
540
- // Build JSON object if available
541
- const hasDetail = !!(ss.parameters || ss.outcome);
542
- let jsonBlock = '';
543
- if (hasDetail) {
544
- const jsonObj = {
545
- toolName: ss.toolName,
546
- page: ss.page,
547
- };
548
- if (ss.parameters) {
549
- jsonObj.parameters = ss.parameters;
550
- }
551
- if (ss.outcome) {
552
- jsonObj.outcome = ss.outcome;
553
- }
554
- jsonBlock = `<pre class="step-json">${esc(JSON.stringify(jsonObj, null, 2))}</pre>`;
711
+ for (const entry of timeline) {
712
+ if (entry.kind === 'donobu') {
713
+ html += renderFilmstripStep(entry.ss, outputDir);
555
714
  }
556
- const hasExpandable = !!(imgSrc || hasDetail);
557
- const expandId = `step-expand-${uid()}`;
558
- html += `<div class="filmstrip-step${hasExpandable ? ' expandable' : ''}"${hasExpandable ? ` onclick="this.classList.toggle('open')"` : ''}>`;
559
- html += `<div class="filmstrip-header">`;
560
- html += `${statusIcon}`;
561
- html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
562
- html += `<span class="filmstrip-duration">${durationStr}</span>`;
563
- if (hasExpandable) {
564
- html += `<span class="filmstrip-chevron">&#9656;</span>`;
565
- }
566
- html += `</div>`;
567
- // Summary line
568
- if (ss.summary) {
569
- html += `<div class="filmstrip-summary">${esc(ss.summary)}</div>`;
570
- }
571
- // Expandable detail area (screenshot + JSON together)
572
- if (hasExpandable) {
573
- html += `<div class="filmstrip-detail" id="${expandId}">`;
574
- if (imgSrc) {
575
- html += `<a href="${esc(imgSrc)}" target="_blank" onclick="event.stopPropagation()"><img src="${esc(imgSrc)}" alt="Step ${ss.index}: ${esc(ss.toolName)}" loading="lazy" class="step-screenshot" /></a>`;
576
- }
577
- if (jsonBlock) {
578
- html += jsonBlock;
579
- }
580
- html += `</div>`;
715
+ else {
716
+ html += renderNativeStep(entry.ns);
581
717
  }
582
- html += `</div>`;
583
718
  }
584
719
  html += '</div>';
720
+ html += '</details>';
721
+ return html;
585
722
  }
586
- else {
587
- // Fallback: no filmstrip data, render log-based steps
588
- html += '<div class="steps-list">';
589
- for (const step of meaningful) {
590
- const typeClass = step.type === 'action' ? 'step-action' : 'step-result';
591
- html += `<div class="step-entry ${typeClass}">`;
592
- html += `<span class="step-time">${esc(step.time)}</span>`;
593
- html += `<span class="step-text">${step.text}</span>`;
594
- html += '</div>';
595
- }
723
+ // Fallback: no filmstrip or native step data — render log-based steps
724
+ const stepCount = meaningful.length;
725
+ let html = '<details class="steps-section"><summary>Steps (' +
726
+ stepCount +
727
+ ')</summary>';
728
+ html += '<div class="steps-list">';
729
+ for (const step of meaningful) {
730
+ const typeClass = step.type === 'action' ? 'step-action' : 'step-result';
731
+ html += `<div class="step-entry ${typeClass}">`;
732
+ html += `<span class="step-time">${esc(step.time)}</span>`;
733
+ html += `<span class="step-text">${step.text}</span>`;
596
734
  html += '</div>';
597
735
  }
736
+ html += '</div>';
598
737
  html += '</details>';
599
738
  return html;
600
739
  }
@@ -703,7 +842,7 @@ function renderQuickActions(test) {
703
842
  html += `<span class="qa-label">${esc(label)}</span>`;
704
843
  html += `<div class="qa-cmd-wrapper">`;
705
844
  html += `<code class="qa-cmd" id="${cmdId}">${esc(cmd)}</code>`;
706
- 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>`;
845
+ html += `<button class="qa-copy" data-copy="${cmdId}" title="Copy to clipboard">Copy</button>`;
707
846
  html += `</div></div>`;
708
847
  }
709
848
  html += sourceHtml;
@@ -813,7 +952,7 @@ function renderEvidenceDeepDive(evidence, outputDir) {
813
952
  const baseSrc = outputDir
814
953
  ? (0, path_1.relative)(outputDir, (0, path_1.resolve)(baseScreenshot))
815
954
  : baseScreenshot;
816
- content += `<a href="${esc(baseSrc)}" target="_blank" onclick="event.stopPropagation()"><img src="${esc(baseSrc)}" alt="Baseline screenshot" loading="lazy" class="screenshot triage-screenshot" /></a>`;
955
+ content += `<a href="${esc(baseSrc)}" target="_blank"><img src="${esc(baseSrc)}" alt="Baseline screenshot" loading="lazy" class="screenshot triage-screenshot" /></a>`;
817
956
  }
818
957
  else {
819
958
  content +=
@@ -828,7 +967,7 @@ function renderEvidenceDeepDive(evidence, outputDir) {
828
967
  const failSrc = outputDir
829
968
  ? (0, path_1.relative)(outputDir, (0, path_1.resolve)(failScreenshot))
830
969
  : failScreenshot;
831
- content += `<a href="${esc(failSrc)}" target="_blank" onclick="event.stopPropagation()"><img src="${esc(failSrc)}" alt="Failure screenshot" loading="lazy" class="screenshot triage-screenshot" /></a>`;
970
+ content += `<a href="${esc(failSrc)}" target="_blank"><img src="${esc(failSrc)}" alt="Failure screenshot" loading="lazy" class="screenshot triage-screenshot" /></a>`;
832
971
  }
833
972
  else {
834
973
  content +=
@@ -878,7 +1017,7 @@ function renderResultTimeline(results, outputDir) {
878
1017
  html += `<div class="timeline-errors">${renderErrors(r.errors)}</div>`;
879
1018
  }
880
1019
  html += renderAttachments(r.attachments, outputDir, r.stepScreenshots);
881
- html += renderSteps(r.steps, r.stepScreenshots, outputDir);
1020
+ html += renderSteps(r.steps, r.stepScreenshots, r.nativeSteps, outputDir);
882
1021
  html += '</div></div>';
883
1022
  }
884
1023
  html += '</div>';
@@ -954,7 +1093,7 @@ function generateHtml(jsonData, triage, outputDir) {
954
1093
  continue;
955
1094
  }
956
1095
  const sc = cfg(card.key);
957
- 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>`;
1096
+ statCardsHtml += `<button class="stat-card" data-filter="${card.key}" title="Click to filter"><div class="stat-count" style="color:${sc.color}">${count}</div><div class="stat-label">${card.label}</div></button>`;
958
1097
  }
959
1098
  // File groups + test cards
960
1099
  let testSectionsHtml = '';
@@ -1009,7 +1148,7 @@ function generateHtml(jsonData, triage, outputDir) {
1009
1148
  }
1010
1149
  // 6. Steps — detailed forensics
1011
1150
  if (!hasMultipleResults && lastResult) {
1012
- detailsHtml += renderSteps(lastResult.steps, lastResult.stepScreenshots, outputDir);
1151
+ detailsHtml += renderSteps(lastResult.steps, lastResult.stepScreenshots, lastResult.nativeSteps, outputDir);
1013
1152
  }
1014
1153
  // 7. Triage details — remediation steps (expandable)
1015
1154
  if (test.plan) {
@@ -1026,7 +1165,7 @@ function generateHtml(jsonData, triage, outputDir) {
1026
1165
  : '<span class="chevron-spacer"></span>';
1027
1166
  const totalTestDuration = test.results.reduce((s, r) => s + r.duration, 0);
1028
1167
  testsHtml += `
1029
- <div class="test-row ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" data-status="${test.status}" ${hasDetails ? `onclick="toggleDetail('${testId}',event)"` : ''}>
1168
+ <div class="test-row ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" data-status="${test.status}" ${hasDetails ? `data-detail="${testId}"` : ''}>
1030
1169
  <div class="test-summary">
1031
1170
  ${chevron}
1032
1171
  <span class="status-dot" style="background:${sc.color}" title="${sc.label}"></span>
@@ -1039,7 +1178,7 @@ function generateHtml(jsonData, triage, outputDir) {
1039
1178
  }
1040
1179
  testSectionsHtml += `
1041
1180
  <div class="file-group ${fileHasFailure ? 'has-failure' : ''}" data-file="${esc(file)}">
1042
- <div class="file-header" onclick="toggleFileGroup(this)">
1181
+ <div class="file-header" data-toggle-file-group>
1043
1182
  <span class="file-chevron">&#9662;</span>
1044
1183
  <span class="file-name">${esc(file)}</span>
1045
1184
  <div class="file-badges">${fileBadgesHtml}</div>
@@ -1162,8 +1301,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
1162
1301
  .healed-icon{font-size:16px}
1163
1302
 
1164
1303
  /* Error / snippet blocks */
1165
- .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}
1304
+ .error-block,.error-block-ansi,.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}
1166
1305
  .error-block{background:rgba(239,68,68,.06);border:1px solid rgba(239,68,68,.15);color:#fca5a5}
1306
+ .error-block-ansi{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted)}
1167
1307
  .snippet-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text)}
1168
1308
  .stack-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted);font-size:11px}
1169
1309
  .snapshot-block{background:var(--bg);border:1px solid var(--border-subtle);color:var(--text-muted);font-size:11px;max-height:300px}
@@ -1205,6 +1345,21 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
1205
1345
  .step-json{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:0;color:var(--text-muted);overflow-x:auto;max-height:300px;overflow-y:auto}
1206
1346
  .step-screenshot{max-width:100%;max-height:250px;width:auto;height:auto;object-fit:contain;border-radius:var(--radius);border:1px solid var(--border);cursor:pointer}
1207
1347
 
1348
+ /* Native Playwright steps (expect / test.step) inside the filmstrip */
1349
+ .native-step{background:var(--bg)}
1350
+ .native-step-title{font-size:12px;font-weight:500;color:var(--text);font-family:var(--mono);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1351
+ .native-step-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0}
1352
+ .native-step-badge--expect{background:rgba(99,102,241,.12);color:#818cf8}
1353
+ .native-step-badge--test\.step{background:rgba(16,185,129,.10);color:#34d399}
1354
+ .native-step-location{font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-left:auto;flex-shrink:0;white-space:nowrap}
1355
+ .native-step-error{font-size:11px;font-family:var(--mono);padding:4px 0 2px 22px;margin:0;white-space:pre-wrap;word-break:break-word;color:var(--text-muted)}
1356
+ .native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px 22px;border-radius:var(--radius);overflow:hidden;border:1px solid var(--border-subtle)}
1357
+ .snippet-line{display:flex;padding:1px 8px;white-space:pre}
1358
+ .snippet-line--target{background:rgba(239,68,68,.10)}
1359
+ .snippet-linenum{color:var(--text-dim);min-width:40px;user-select:none}
1360
+ .snippet-line--target .snippet-linenum{color:var(--red)}
1361
+ .snippet-code{color:var(--text)}
1362
+
1208
1363
  /* Result timeline */
1209
1364
  .result-timeline{position:relative;padding-left:20px}
1210
1365
  .timeline-entry{position:relative;padding-bottom:16px;padding-left:16px;border-left:2px solid var(--border)}
@@ -1349,7 +1504,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
1349
1504
 
1350
1505
  <div class="filter-bar" id="filterBar">
1351
1506
  <span>Showing: <strong id="filterLabel"></strong></span>
1352
- <button class="clear-filter" onclick="clearFilter()">Clear filter</button>
1507
+ <button class="clear-filter" data-clear-filter>Clear filter</button>
1353
1508
  </div>
1354
1509
 
1355
1510
  ${testSectionsHtml}
@@ -1358,51 +1513,75 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica N
1358
1513
  <div class="report-footer">Report generated by Donobu</div>
1359
1514
  </div>
1360
1515
 
1361
- <div class="lightbox" id="lightbox" onclick="closeLightbox()">
1516
+ <div class="lightbox" id="lightbox" data-lightbox>
1362
1517
  <img id="lightboxImg" src="" alt="Screenshot" />
1363
1518
  </div>
1364
1519
 
1365
1520
  <script>
1366
- 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')}}
1367
- function toggleFileGroup(h){h.closest('.file-group').classList.toggle('collapsed')}
1521
+ (function(){
1522
+ var activeFilter=null;
1523
+ function filterByStatus(s){
1524
+ if(activeFilter===s){clearFilter();return}
1525
+ activeFilter=s;
1526
+ document.querySelectorAll('.stat-card').forEach(function(c){c.classList.toggle('active-filter',c.getAttribute('data-filter')===s)});
1527
+ document.getElementById('filterBar').classList.add('visible');
1528
+ document.getElementById('filterLabel').textContent=s.charAt(0).toUpperCase()+s.slice(1);
1529
+ document.querySelectorAll('.test-row').forEach(function(r){r.classList.toggle('hidden-by-filter',r.getAttribute('data-status')!==s)});
1530
+ document.querySelectorAll('.file-group').forEach(function(g){
1531
+ var vis=g.querySelectorAll('.test-row:not(.hidden-by-filter)');
1532
+ g.classList.toggle('hidden',vis.length===0);
1533
+ g.classList.remove('collapsed');
1534
+ });
1535
+ }
1536
+ function clearFilter(){
1537
+ activeFilter=null;
1538
+ document.querySelectorAll('.stat-card').forEach(function(c){c.classList.remove('active-filter')});
1539
+ document.getElementById('filterBar').classList.remove('visible');
1540
+ document.querySelectorAll('.test-row').forEach(function(r){r.classList.remove('hidden-by-filter')});
1541
+ document.querySelectorAll('.file-group').forEach(function(g){g.classList.remove('hidden')});
1542
+ }
1543
+ function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
1368
1544
 
1369
- var activeFilter=null;
1370
- function filterByStatus(s){
1371
- if(activeFilter===s){clearFilter();return}
1372
- activeFilter=s;
1373
- document.querySelectorAll('.stat-card').forEach(function(c){c.classList.toggle('active-filter',c.getAttribute('data-filter')===s)});
1374
- document.getElementById('filterBar').classList.add('visible');
1375
- document.getElementById('filterLabel').textContent=s.charAt(0).toUpperCase()+s.slice(1);
1376
- document.querySelectorAll('.test-row').forEach(function(r){r.classList.toggle('hidden-by-filter',r.getAttribute('data-status')!==s)});
1377
- document.querySelectorAll('.file-group').forEach(function(g){
1378
- var vis=g.querySelectorAll('.test-row:not(.hidden-by-filter)');
1379
- g.classList.toggle('hidden',vis.length===0);
1380
- g.classList.remove('collapsed');
1545
+ document.addEventListener('click',function(e){
1546
+ // Close lightbox when clicking its backdrop
1547
+ if(e.target.closest('[data-lightbox]')&&!e.target.closest('#lightboxImg')){closeLightbox();return}
1548
+ // Open lightbox for screenshot images
1549
+ var img=e.target.closest('.screenshot');
1550
+ if(img){
1551
+ e.preventDefault();e.stopPropagation();
1552
+ var src=img.closest('a')?img.closest('a').href:img.src;
1553
+ document.getElementById('lightboxImg').src=src;
1554
+ document.getElementById('lightbox').classList.add('open');
1555
+ return;
1556
+ }
1557
+ // Copy button
1558
+ var copyBtn=e.target.closest('[data-copy]');
1559
+ if(copyBtn){
1560
+ var el=document.getElementById(copyBtn.getAttribute('data-copy'));
1561
+ if(el){navigator.clipboard.writeText(el.textContent);copyBtn.textContent='Copied!';setTimeout(function(){copyBtn.textContent='Copy'},1500)}
1562
+ return;
1563
+ }
1564
+ // Stat card filter
1565
+ var card=e.target.closest('.stat-card[data-filter]');
1566
+ if(card){filterByStatus(card.getAttribute('data-filter'));return}
1567
+ // Clear filter
1568
+ if(e.target.closest('[data-clear-filter]')){clearFilter();return}
1569
+ // Filmstrip step expand (skip if clicking a link inside)
1570
+ var step=e.target.closest('.filmstrip-step.expandable');
1571
+ if(step&&!e.target.closest('a')){step.classList.toggle('open');return}
1572
+ // File group collapse/expand
1573
+ var fileHeader=e.target.closest('[data-toggle-file-group]');
1574
+ if(fileHeader){fileHeader.closest('.file-group').classList.toggle('collapsed');return}
1575
+ // Test row expand
1576
+ var row=e.target.closest('.test-row[data-detail]');
1577
+ if(row&&!e.target.closest('.test-detail')){row.classList.toggle('expanded');return}
1381
1578
  });
1382
- }
1383
- function clearFilter(){
1384
- activeFilter=null;
1385
- document.querySelectorAll('.stat-card').forEach(function(c){c.classList.remove('active-filter')});
1386
- document.getElementById('filterBar').classList.remove('visible');
1387
- document.querySelectorAll('.test-row').forEach(function(r){r.classList.remove('hidden-by-filter')});
1388
- document.querySelectorAll('.file-group').forEach(function(g){g.classList.remove('hidden')});
1389
- }
1390
1579
 
1391
- // Screenshot lightbox
1392
- document.addEventListener('click',function(e){
1393
- var img=e.target.closest('.screenshot');
1394
- if(img){
1395
- e.preventDefault();e.stopPropagation();
1396
- var src=img.closest('a')?img.closest('a').href:img.src;
1397
- document.getElementById('lightboxImg').src=src;
1398
- document.getElementById('lightbox').classList.add('open');
1399
- }
1400
- });
1401
- function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
1402
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
1580
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
1403
1581
 
1404
- // Auto-expand failed/timedout/interrupted/healed tests
1405
- document.querySelectorAll('.test-row.failed,.test-row.timedout,.test-row.interrupted,.test-row.healed').forEach(function(r){r.classList.add('expanded')});
1582
+ // Auto-expand failed/timedout/interrupted/healed tests
1583
+ document.querySelectorAll('.test-row.failed,.test-row.timedout,.test-row.interrupted,.test-row.healed').forEach(function(r){r.classList.add('expanded')});
1584
+ })();
1406
1585
  </script>
1407
1586
  </body>
1408
1587
  </html>`;
@@ -1410,69 +1589,73 @@ document.querySelectorAll('.test-row.failed,.test-row.timedout,.test-row.interru
1410
1589
  // ---------------------------------------------------------------------------
1411
1590
  // Main
1412
1591
  // ---------------------------------------------------------------------------
1413
- try {
1414
- const { jsonData, outputFile, triageDir, inputFile } = parseArgs();
1415
- // Auto-discover triage dir from merged report metadata if not explicitly given
1416
- let resolvedTriageDir = triageDir;
1417
- // Prefer the explicit triageRunDir recorded during the merge.
1418
- if (!resolvedTriageDir && jsonData.metadata?.triageRunDir) {
1419
- const candidate = jsonData.metadata.triageRunDir;
1420
- if ((0, fs_1.existsSync)(candidate)) {
1421
- resolvedTriageDir = candidate;
1422
- }
1423
- }
1424
- // Legacy fallback: derive triage dir from the initial report path (older
1425
- // merged reports stored the initial-playwright-report.json copy inside the
1426
- // triage run directory).
1427
- if (!resolvedTriageDir && jsonData.metadata?.sources?.initial) {
1428
- const initialPath = jsonData.metadata.sources.initial;
1429
- // Try the absolute path first (works when running in the same env as CI)
1430
- const candidate = (0, path_1.dirname)(initialPath);
1431
- if ((0, fs_1.existsSync)(candidate) && (0, path_1.basename)(candidate) !== '.') {
1432
- resolvedTriageDir = candidate;
1592
+ // Only run CLI logic when this file is executed directly (not imported as a module).
1593
+ if (require.main === module) {
1594
+ try {
1595
+ const { jsonData, outputFile, triageDir, inputFile } = parseArgs();
1596
+ // Auto-discover triage dir from merged report metadata if not explicitly given
1597
+ let resolvedTriageDir = triageDir;
1598
+ // Prefer the explicit triageRunDir recorded during the merge.
1599
+ if (!resolvedTriageDir && jsonData.metadata?.triageRunDir) {
1600
+ const candidate = jsonData.metadata.triageRunDir;
1601
+ if ((0, fs_1.existsSync)(candidate)) {
1602
+ resolvedTriageDir = candidate;
1603
+ }
1433
1604
  }
1434
- else {
1435
- // Fallback: extract the donobu-triage/... suffix and resolve relative
1436
- // to the input file. This handles downloaded CI artifacts where the
1437
- // absolute CI path no longer exists.
1438
- const triageMatch = initialPath.match(/donobu-triage[/\\][^/\\]+[/\\]?.*$/);
1439
- if (triageMatch && inputFile) {
1440
- const localCandidate = (0, path_1.resolve)((0, path_1.dirname)((0, path_1.resolve)(inputFile)), (0, path_1.dirname)(triageMatch[0]));
1441
- if ((0, fs_1.existsSync)(localCandidate)) {
1442
- resolvedTriageDir = localCandidate;
1605
+ // Legacy fallback: derive triage dir from the initial report path (older
1606
+ // merged reports stored the initial-playwright-report.json copy inside the
1607
+ // triage run directory).
1608
+ if (!resolvedTriageDir && jsonData.metadata?.sources?.initial) {
1609
+ const initialPath = jsonData.metadata.sources.initial;
1610
+ // Try the absolute path first (works when running in the same env as CI)
1611
+ const candidate = (0, path_1.dirname)(initialPath);
1612
+ if ((0, fs_1.existsSync)(candidate) && (0, path_1.basename)(candidate) !== '.') {
1613
+ resolvedTriageDir = candidate;
1614
+ }
1615
+ else {
1616
+ // Fallback: extract the donobu-triage/... suffix and resolve relative
1617
+ // to the input file. This handles downloaded CI artifacts where the
1618
+ // absolute CI path no longer exists.
1619
+ const triageMatch = initialPath.match(/donobu-triage[/\\][^/\\]+[/\\]?.*$/);
1620
+ if (triageMatch && inputFile) {
1621
+ const localCandidate = (0, path_1.resolve)((0, path_1.dirname)((0, path_1.resolve)(inputFile)), (0, path_1.dirname)(triageMatch[0]));
1622
+ if ((0, fs_1.existsSync)(localCandidate)) {
1623
+ resolvedTriageDir = localCandidate;
1624
+ }
1443
1625
  }
1444
1626
  }
1445
1627
  }
1446
- }
1447
- // Last resort: scan for donobu-triage dirs next to the input file
1448
- if (!resolvedTriageDir && inputFile) {
1449
- const triageRoot = (0, path_1.resolve)((0, path_1.dirname)((0, path_1.resolve)(inputFile)), 'donobu-triage');
1450
- if ((0, fs_1.existsSync)(triageRoot)) {
1451
- // Use the most recent triage run directory
1452
- const subdirs = (0, fs_1.readdirSync)(triageRoot)
1453
- .filter((d) => (0, fs_1.existsSync)((0, path_1.resolve)(triageRoot, d, '.')))
1454
- .sort()
1455
- .reverse();
1456
- if (subdirs.length > 0) {
1457
- resolvedTriageDir = (0, path_1.resolve)(triageRoot, subdirs[0]);
1628
+ // Last resort: scan for donobu-triage dirs next to the input file
1629
+ if (!resolvedTriageDir && inputFile) {
1630
+ const triageRoot = (0, path_1.resolve)((0, path_1.dirname)((0, path_1.resolve)(inputFile)), 'donobu-triage');
1631
+ if ((0, fs_1.existsSync)(triageRoot)) {
1632
+ // Use the most recent triage run directory
1633
+ const subdirs = (0, fs_1.readdirSync)(triageRoot)
1634
+ .filter((d) => (0, fs_1.existsSync)((0, path_1.resolve)(triageRoot, d, '.')))
1635
+ .sort()
1636
+ .reverse();
1637
+ if (subdirs.length > 0) {
1638
+ resolvedTriageDir = (0, path_1.resolve)(triageRoot, subdirs[0]);
1639
+ }
1458
1640
  }
1459
1641
  }
1642
+ const triage = resolvedTriageDir
1643
+ ? loadTriageData(resolvedTriageDir)
1644
+ : { plans: [], evidence: [] };
1645
+ const outputDir = outputFile ? (0, path_1.dirname)((0, path_1.resolve)(outputFile)) : null;
1646
+ const html = generateHtml(jsonData, triage, outputDir);
1647
+ if (outputFile) {
1648
+ (0, fs_1.writeFileSync)(outputFile, html, 'utf8');
1649
+ console.error(`Report written to ${outputFile}`);
1650
+ }
1651
+ else {
1652
+ console.log(html);
1653
+ }
1460
1654
  }
1461
- const triage = resolvedTriageDir
1462
- ? loadTriageData(resolvedTriageDir)
1463
- : { plans: [], evidence: [] };
1464
- const outputDir = outputFile ? (0, path_1.dirname)((0, path_1.resolve)(outputFile)) : null;
1465
- const html = generateHtml(jsonData, triage, outputDir);
1466
- if (outputFile) {
1467
- (0, fs_1.writeFileSync)(outputFile, html, 'utf8');
1468
- console.error(`Report written to ${outputFile}`);
1469
- }
1470
- else {
1471
- console.log(html);
1655
+ catch (error) {
1656
+ console.error('Error processing JSON:', error.message);
1657
+ process.exit(1);
1472
1658
  }
1473
1659
  }
1474
- catch (error) {
1475
- console.error('Error processing JSON:', error.message);
1476
- process.exit(1);
1477
- }
1660
+ // end of CLI guard
1478
1661
  //# sourceMappingURL=playwright-json-to-html.js.map