donobu 5.36.5 → 5.36.6

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.
@@ -809,10 +809,28 @@ async function attachStepScreenshots(sharedState, testInfo) {
809
809
  const bytes = await sharedState.persistence.getScreenShot(flowId, tc.postCallImageId);
810
810
  if (bytes) {
811
811
  const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
812
- await testInfo.attach(`donobu-step-${i}-${tc.toolName}`, {
813
- body: bytes,
814
- contentType: isJpeg ? 'image/jpeg' : 'image/png',
815
- });
812
+ const ext = isJpeg ? 'jpg' : 'png';
813
+ // Write the bytes to a temporary file so we can attach by `path`
814
+ // rather than `body`. Attaching by body would keep the bytes in
815
+ // memory and pass them through to reporters, which then base64
816
+ // them into the HTML — the very thing we're trying to avoid. By
817
+ // contrast `attach({ path })` makes Playwright copy the file into
818
+ // `<outputDir>/attachments/<sha1>.<ext>` and only carry the path.
819
+ // We unlink our temp file afterwards so the test-results tree
820
+ // doesn't keep a duplicate next to the attachments folder.
821
+ const filePath = testInfo.outputPath(`donobu-step-${i}-${tc.toolName}.${ext}`);
822
+ await fs_1.promises.writeFile(filePath, bytes);
823
+ try {
824
+ await testInfo.attach(`donobu-step-${i}-${tc.toolName}`, {
825
+ path: filePath,
826
+ contentType: isJpeg ? 'image/jpeg' : 'image/png',
827
+ });
828
+ }
829
+ finally {
830
+ await fs_1.promises.unlink(filePath).catch(() => {
831
+ /* best-effort cleanup; attach already copied the bytes */
832
+ });
833
+ }
816
834
  }
817
835
  }
818
836
  catch {
@@ -78,8 +78,15 @@ function buildDonobuReport(resultsByTest) {
78
78
  contentType: a.contentType,
79
79
  path: a.path ?? null,
80
80
  // Playwright Reporter API provides body as a Buffer; the HTML
81
- // generator expects a base64-encoded string (matching JSON format).
82
- body: a.body ? a.body.toString('base64') : undefined,
81
+ // generator expects a base64-encoded string (matching JSON
82
+ // format). When `path` is set the renderer prefers it, so skip
83
+ // the base64 inflation — large image attachments end up as
84
+ // sidecar files on disk rather than embedded in the report.
85
+ body: a.path
86
+ ? undefined
87
+ : a.body
88
+ ? a.body.toString('base64')
89
+ : undefined,
83
90
  })),
84
91
  // TestResult.stderr is already Array<{text?: string; buffer?: string}>,
85
92
  // which is the same shape parseStderrSteps expects.
@@ -525,9 +525,10 @@ function renderAttachments(attachments, outputDir, stepScreenshots = []) {
525
525
  const hasScreenshot = rendered.some((r) => r.includes('class="screenshot"'));
526
526
  if (!hasScreenshot && stepScreenshots.length > 0) {
527
527
  const lastStep = stepScreenshots[stepScreenshots.length - 1];
528
- if (lastStep.imageBody && lastStep.imageContentType) {
528
+ const imgSrc = resolveStepImageSrc(lastStep, outputDir);
529
+ if (imgSrc) {
529
530
  const imgLabel = 'Screenshot at test completion';
530
- rendered.push(`<div class="img-wrapper"><img src="data:${lastStep.imageContentType};base64,${lastStep.imageBody}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /><span class="img-label">${esc(imgLabel)} (from last step)</span></div>`);
531
+ rendered.push(`<div class="img-wrapper"><img src="${esc(imgSrc)}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /><span class="img-label">${esc(imgLabel)} (from last step)</span></div>`);
531
532
  }
532
533
  }
533
534
  if (!rendered.length) {
@@ -591,16 +592,14 @@ function renderNativeStep(ns, childrenHtml) {
591
592
  const hasBody = !!snippet || hasError || !!childrenHtml;
592
593
  const renderHeader = (tag) => {
593
594
  let header = `<${tag} class="filmstrip-header">`;
595
+ header +=
596
+ '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
594
597
  header += statusIcon;
595
598
  header += `<span class="native-step-title">${esc(ns.title)}</span>`;
596
599
  header += categoryBadge;
597
600
  if (locationStr) {
598
601
  header += `<span class="native-step-location">${locationStr}</span>`;
599
602
  }
600
- if (tag === 'summary') {
601
- header +=
602
- '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
603
- }
604
603
  header += `</${tag}>`;
605
604
  return header;
606
605
  };
@@ -613,7 +612,7 @@ function renderNativeStep(ns, childrenHtml) {
613
612
  // keep tests with many assertions scannable.
614
613
  const defaultOpen = !ns.passed || (ns.category === 'test.step' && !!childrenHtml);
615
614
  const passClass = ns.passed ? 'native-step--passed' : 'native-step--failed';
616
- let html = `<details class="filmstrip-step native-step ${passClass}"${defaultOpen ? ' open' : ''}>`;
615
+ let html = `<details class="filmstrip-step native-step expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
617
616
  html += renderHeader('summary');
618
617
  if (hasError) {
619
618
  html += `<pre class="native-step-error">${ansiToHtml(ns.error.message)}</pre>`;
@@ -688,14 +687,12 @@ function renderAiInvocation(inv, childrenHtml) {
688
687
  const hasBody = hasError || !!childrenHtml || hasAssertSteps;
689
688
  const renderHeader = (tag) => {
690
689
  let header = `<${tag} class="filmstrip-header">`;
690
+ header +=
691
+ '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
691
692
  header += statusIcon;
692
693
  header += `<span class="ai-invocation-title">${esc(inv.description)}</span>`;
693
694
  header += kindBadge;
694
695
  header += cachedBadge;
695
- if (tag === 'summary') {
696
- header +=
697
- '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
698
- }
699
696
  header += `</${tag}>`;
700
697
  return header;
701
698
  };
@@ -711,7 +708,7 @@ function renderAiInvocation(inv, childrenHtml) {
711
708
  const passClass = inv.passed
712
709
  ? 'ai-invocation--passed'
713
710
  : 'ai-invocation--failed';
714
- let html = `<details class="filmstrip-step ai-invocation ${passClass}"${defaultOpen ? ' open' : ''}>`;
711
+ let html = `<details class="filmstrip-step ai-invocation expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
715
712
  html += renderHeader('summary');
716
713
  if (hasError) {
717
714
  html += `<pre class="native-step-error">${ansiToHtml(inv.error.message)}</pre>`;
@@ -906,19 +903,22 @@ function renderAuditReport(metadata) {
906
903
  html += '</div></div>';
907
904
  return html;
908
905
  }
906
+ function resolveStepImageSrc(ss, outputDir) {
907
+ if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
908
+ return outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
909
+ }
910
+ if (ss.imageBody && ss.imageContentType) {
911
+ return `data:${ss.imageContentType};base64,${ss.imageBody}`;
912
+ }
913
+ return null;
914
+ }
909
915
  function renderFilmstripStep(ss, outputDir) {
910
916
  const duration = ss.completedAt - ss.startedAt;
911
917
  const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
912
918
  const statusIcon = ss.success
913
919
  ? '<span class="step-status-ok">&#10003;</span>'
914
920
  : '<span class="step-status-fail">&#10007;</span>';
915
- let imgSrc = null;
916
- if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
917
- imgSrc = outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
918
- }
919
- else if (ss.imageBody && ss.imageContentType) {
920
- imgSrc = `data:${ss.imageContentType};base64,${ss.imageBody}`;
921
- }
921
+ const imgSrc = resolveStepImageSrc(ss, outputDir);
922
922
  // For the audit tool, render a structured report instead of raw JSON.
923
923
  const isAudit = ss.toolName === 'audit' && ss.metadata && 'pageLoad' in ss.metadata;
924
924
  let detailBlock = '';
@@ -946,12 +946,10 @@ function renderFilmstripStep(ss, outputDir) {
946
946
  const expandId = `step-expand-${uid()}`;
947
947
  let html = `<div class="filmstrip-step${hasExpandable ? ' expandable' : ''}">`;
948
948
  html += `<div class="filmstrip-header">`;
949
+ html += `<span class="filmstrip-chevron" aria-hidden="true">&#9656;</span>`;
949
950
  html += statusIcon;
950
951
  html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
951
952
  html += `<span class="filmstrip-duration">${durationStr}</span>`;
952
- if (hasExpandable) {
953
- html += `<span class="filmstrip-chevron">&#9656;</span>`;
954
- }
955
953
  html += `</div>`;
956
954
  if (ss.summary) {
957
955
  html += `<div class="filmstrip-summary">${esc(ss.summary)}</div>`;
@@ -1757,12 +1755,13 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1757
1755
  .filmstrip-duration{font-size:11px;color:var(--text-dim);font-family:var(--mono)}
1758
1756
  .filmstrip-step.expandable{cursor:pointer}
1759
1757
  .filmstrip-step.expandable:hover{background:var(--bg)}
1760
- .filmstrip-chevron{margin-left:auto;font-size:11px;color:var(--text-dim);transition:transform .15s}
1758
+ .filmstrip-chevron,.native-step-chevron{font-size:13px;color:var(--text-muted);width:14px;display:inline-flex;justify-content:center;flex-shrink:0;transition:transform .15s}
1759
+ .filmstrip-step:not(.expandable) .filmstrip-chevron,.filmstrip-step:not(.expandable) .native-step-chevron{visibility:hidden}
1761
1760
  .filmstrip-step.open .filmstrip-chevron{transform:rotate(90deg)}
1762
- .filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;padding-left:22px}
1761
+ .filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;padding-left:44px}
1763
1762
  .step-status-ok{color:var(--green);font-size:12px;font-weight:bold}
1764
1763
  .step-status-fail{color:var(--red);font-size:12px;font-weight:bold}
1765
- .filmstrip-detail{display:none;padding:8px 0 4px 22px;flex-direction:row;gap:12px;align-items:flex-start}
1764
+ .filmstrip-detail{display:none;padding:8px 0 4px 44px;flex-direction:row;gap:12px;align-items:flex-start}
1766
1765
  .filmstrip-step.open .filmstrip-detail{display:flex}
1767
1766
  .filmstrip-detail>a{flex-shrink:0;max-width:50%}
1768
1767
  .filmstrip-detail>.step-json-wrap{flex:1;min-width:0}
@@ -1822,10 +1821,9 @@ details.native-step>summary::-webkit-details-marker{display:none}
1822
1821
  .native-step-badge--expect{background:rgba(99,102,241,.12);color:#818cf8}
1823
1822
  .native-step-badge--test\.step{background:rgba(16,185,129,.10);color:#34d399}
1824
1823
  .native-step-location{font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-left:auto;flex-shrink:0;white-space:nowrap}
1825
- .native-step-chevron{font-size:10px;color:var(--text-dim);flex-shrink:0;transition:transform .12s;display:inline-block;margin-left:4px}
1826
1824
  details.native-step[open]>summary .native-step-chevron{transform:rotate(90deg)}
1827
- .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)}
1828
- .native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px 22px;overflow:hidden}
1825
+ .native-step-error{font-size:11px;font-family:var(--mono);padding:4px 0 2px 44px;margin:0;white-space:pre-wrap;word-break:break-word;color:var(--text-muted)}
1826
+ .native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px 44px;overflow:hidden}
1829
1827
  .native-step-children{display:flex;flex-direction:column;margin:4px 0 0 10px;border-left:1px solid var(--border-subtle);padding-left:8px}
1830
1828
  .native-step-children>.filmstrip-step{padding-left:8px}
1831
1829
 
@@ -1839,7 +1837,7 @@ details.ai-invocation>summary::-webkit-details-marker{display:none}
1839
1837
  .ai-invocation-badge--locate{background:rgba(59,130,246,.12);color:#60a5fa}
1840
1838
  .ai-cached-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0;background:rgba(245,158,11,.12);color:#fbbf24}
1841
1839
  details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)}
1842
- .ai-assert-steps{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:6px 0 2px 22px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:240px;overflow-y:auto}
1840
+ .ai-assert-steps{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:6px 0 2px 44px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:240px;overflow-y:auto}
1843
1841
  .snippet-line{display:flex;padding:1px 8px;white-space:pre}
1844
1842
  .snippet-line--target{background:rgba(239,68,68,.10)}
1845
1843
  .snippet-linenum{color:var(--text-dim);min-width:40px;user-select:none}
@@ -1961,7 +1959,7 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
1961
1959
  /* Lightbox */
1962
1960
  .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}
1963
1961
  .lightbox.open{display:flex}
1964
- .lightbox img{max-width:95vw;max-height:95vh;border-radius:var(--radius)}
1962
+ .lightbox img{max-width:85vw;max-height:85vh;border-radius:var(--radius)}
1965
1963
 
1966
1964
  /* Print */
1967
1965
  @media print{
@@ -2024,9 +2022,12 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2024
2022
  document.addEventListener('click',function(e){
2025
2023
  // Close lightbox when clicking its backdrop
2026
2024
  if(e.target.closest('[data-lightbox]')&&!e.target.closest('#lightboxImg')){closeLightbox();return}
2027
- // Open lightbox for screenshot images
2028
- var img=e.target.closest('.screenshot');
2029
- if(img){
2025
+ // Open lightbox for screenshot images. Skip on modified clicks so the
2026
+ // wrapping <a target="_blank"> still handles cmd/ctrl/shift/middle-click
2027
+ // (open-in-new-tab). Right-click goes through the contextmenu event and
2028
+ // is never intercepted here, so "Save image as..." keeps working.
2029
+ var img=e.target.closest('.screenshot, .step-screenshot');
2030
+ if(img&&e.button===0&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey&&!e.altKey){
2030
2031
  e.preventDefault();e.stopPropagation();
2031
2032
  var src=img.closest('a')?img.closest('a').href:img.src;
2032
2033
  document.getElementById('lightboxImg').src=src;
@@ -2053,9 +2054,22 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2053
2054
  // Test bar block click — scroll to and highlight the target test card
2054
2055
  var barBlock=e.target.closest('.test-bar-block[data-target]');
2055
2056
  if(barBlock){var tid=barBlock.getAttribute('data-target');var card=document.getElementById(tid);if(card){var tc=card.closest('.test-card');if(tc){tc.classList.add('expanded');tc.scrollIntoView({behavior:'smooth',block:'center'});tc.style.outline='2px solid var(--accent)';setTimeout(function(){tc.style.outline=''},1500)}}return}
2056
- // Filmstrip step expand (skip if clicking a link inside)
2057
+ // Filmstrip step expand (skip if clicking a link inside).
2058
+ // <div>-based steps: toggle the open class on any click in the wrapper.
2059
+ // <details>-based steps: <summary> clicks are handled natively; padding
2060
+ // clicks (which hit the <details> element directly, not a child) need
2061
+ // a manual toggle so the entire visible row is the click target.
2062
+ // Body-content clicks are intentionally left alone so snippet text
2063
+ // stays selectable.
2057
2064
  var step=e.target.closest('.filmstrip-step.expandable');
2058
- if(step&&!e.target.closest('a')&&!e.target.closest('.audit-check')){step.classList.toggle('open');return}
2065
+ if(step&&!e.target.closest('a')&&!e.target.closest('.audit-check')){
2066
+ if(step.tagName==='DETAILS'){
2067
+ if(e.target===step){step.open=!step.open}
2068
+ }else{
2069
+ step.classList.toggle('open');
2070
+ }
2071
+ return;
2072
+ }
2059
2073
  // Audit check row expand
2060
2074
  var auditCheck=e.target.closest('.audit-check.expandable');
2061
2075
  if(auditCheck){auditCheck.classList.toggle('open');return}
@@ -809,10 +809,28 @@ async function attachStepScreenshots(sharedState, testInfo) {
809
809
  const bytes = await sharedState.persistence.getScreenShot(flowId, tc.postCallImageId);
810
810
  if (bytes) {
811
811
  const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
812
- await testInfo.attach(`donobu-step-${i}-${tc.toolName}`, {
813
- body: bytes,
814
- contentType: isJpeg ? 'image/jpeg' : 'image/png',
815
- });
812
+ const ext = isJpeg ? 'jpg' : 'png';
813
+ // Write the bytes to a temporary file so we can attach by `path`
814
+ // rather than `body`. Attaching by body would keep the bytes in
815
+ // memory and pass them through to reporters, which then base64
816
+ // them into the HTML — the very thing we're trying to avoid. By
817
+ // contrast `attach({ path })` makes Playwright copy the file into
818
+ // `<outputDir>/attachments/<sha1>.<ext>` and only carry the path.
819
+ // We unlink our temp file afterwards so the test-results tree
820
+ // doesn't keep a duplicate next to the attachments folder.
821
+ const filePath = testInfo.outputPath(`donobu-step-${i}-${tc.toolName}.${ext}`);
822
+ await fs_1.promises.writeFile(filePath, bytes);
823
+ try {
824
+ await testInfo.attach(`donobu-step-${i}-${tc.toolName}`, {
825
+ path: filePath,
826
+ contentType: isJpeg ? 'image/jpeg' : 'image/png',
827
+ });
828
+ }
829
+ finally {
830
+ await fs_1.promises.unlink(filePath).catch(() => {
831
+ /* best-effort cleanup; attach already copied the bytes */
832
+ });
833
+ }
816
834
  }
817
835
  }
818
836
  catch {
@@ -78,8 +78,15 @@ function buildDonobuReport(resultsByTest) {
78
78
  contentType: a.contentType,
79
79
  path: a.path ?? null,
80
80
  // Playwright Reporter API provides body as a Buffer; the HTML
81
- // generator expects a base64-encoded string (matching JSON format).
82
- body: a.body ? a.body.toString('base64') : undefined,
81
+ // generator expects a base64-encoded string (matching JSON
82
+ // format). When `path` is set the renderer prefers it, so skip
83
+ // the base64 inflation — large image attachments end up as
84
+ // sidecar files on disk rather than embedded in the report.
85
+ body: a.path
86
+ ? undefined
87
+ : a.body
88
+ ? a.body.toString('base64')
89
+ : undefined,
83
90
  })),
84
91
  // TestResult.stderr is already Array<{text?: string; buffer?: string}>,
85
92
  // which is the same shape parseStderrSteps expects.
@@ -525,9 +525,10 @@ function renderAttachments(attachments, outputDir, stepScreenshots = []) {
525
525
  const hasScreenshot = rendered.some((r) => r.includes('class="screenshot"'));
526
526
  if (!hasScreenshot && stepScreenshots.length > 0) {
527
527
  const lastStep = stepScreenshots[stepScreenshots.length - 1];
528
- if (lastStep.imageBody && lastStep.imageContentType) {
528
+ const imgSrc = resolveStepImageSrc(lastStep, outputDir);
529
+ if (imgSrc) {
529
530
  const imgLabel = 'Screenshot at test completion';
530
- rendered.push(`<div class="img-wrapper"><img src="data:${lastStep.imageContentType};base64,${lastStep.imageBody}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /><span class="img-label">${esc(imgLabel)} (from last step)</span></div>`);
531
+ rendered.push(`<div class="img-wrapper"><img src="${esc(imgSrc)}" alt="${esc(imgLabel)}" loading="lazy" class="screenshot" /><span class="img-label">${esc(imgLabel)} (from last step)</span></div>`);
531
532
  }
532
533
  }
533
534
  if (!rendered.length) {
@@ -591,16 +592,14 @@ function renderNativeStep(ns, childrenHtml) {
591
592
  const hasBody = !!snippet || hasError || !!childrenHtml;
592
593
  const renderHeader = (tag) => {
593
594
  let header = `<${tag} class="filmstrip-header">`;
595
+ header +=
596
+ '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
594
597
  header += statusIcon;
595
598
  header += `<span class="native-step-title">${esc(ns.title)}</span>`;
596
599
  header += categoryBadge;
597
600
  if (locationStr) {
598
601
  header += `<span class="native-step-location">${locationStr}</span>`;
599
602
  }
600
- if (tag === 'summary') {
601
- header +=
602
- '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
603
- }
604
603
  header += `</${tag}>`;
605
604
  return header;
606
605
  };
@@ -613,7 +612,7 @@ function renderNativeStep(ns, childrenHtml) {
613
612
  // keep tests with many assertions scannable.
614
613
  const defaultOpen = !ns.passed || (ns.category === 'test.step' && !!childrenHtml);
615
614
  const passClass = ns.passed ? 'native-step--passed' : 'native-step--failed';
616
- let html = `<details class="filmstrip-step native-step ${passClass}"${defaultOpen ? ' open' : ''}>`;
615
+ let html = `<details class="filmstrip-step native-step expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
617
616
  html += renderHeader('summary');
618
617
  if (hasError) {
619
618
  html += `<pre class="native-step-error">${ansiToHtml(ns.error.message)}</pre>`;
@@ -688,14 +687,12 @@ function renderAiInvocation(inv, childrenHtml) {
688
687
  const hasBody = hasError || !!childrenHtml || hasAssertSteps;
689
688
  const renderHeader = (tag) => {
690
689
  let header = `<${tag} class="filmstrip-header">`;
690
+ header +=
691
+ '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
691
692
  header += statusIcon;
692
693
  header += `<span class="ai-invocation-title">${esc(inv.description)}</span>`;
693
694
  header += kindBadge;
694
695
  header += cachedBadge;
695
- if (tag === 'summary') {
696
- header +=
697
- '<span class="native-step-chevron" aria-hidden="true">&#9656;</span>';
698
- }
699
696
  header += `</${tag}>`;
700
697
  return header;
701
698
  };
@@ -711,7 +708,7 @@ function renderAiInvocation(inv, childrenHtml) {
711
708
  const passClass = inv.passed
712
709
  ? 'ai-invocation--passed'
713
710
  : 'ai-invocation--failed';
714
- let html = `<details class="filmstrip-step ai-invocation ${passClass}"${defaultOpen ? ' open' : ''}>`;
711
+ let html = `<details class="filmstrip-step ai-invocation expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
715
712
  html += renderHeader('summary');
716
713
  if (hasError) {
717
714
  html += `<pre class="native-step-error">${ansiToHtml(inv.error.message)}</pre>`;
@@ -906,19 +903,22 @@ function renderAuditReport(metadata) {
906
903
  html += '</div></div>';
907
904
  return html;
908
905
  }
906
+ function resolveStepImageSrc(ss, outputDir) {
907
+ if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
908
+ return outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
909
+ }
910
+ if (ss.imageBody && ss.imageContentType) {
911
+ return `data:${ss.imageContentType};base64,${ss.imageBody}`;
912
+ }
913
+ return null;
914
+ }
909
915
  function renderFilmstripStep(ss, outputDir) {
910
916
  const duration = ss.completedAt - ss.startedAt;
911
917
  const durationStr = duration >= 1000 ? `${(duration / 1000).toFixed(1)}s` : `${duration}ms`;
912
918
  const statusIcon = ss.success
913
919
  ? '<span class="step-status-ok">&#10003;</span>'
914
920
  : '<span class="step-status-fail">&#10007;</span>';
915
- let imgSrc = null;
916
- if (ss.imagePath && (0, fs_1.existsSync)(ss.imagePath)) {
917
- imgSrc = outputDir ? (0, path_1.relative)(outputDir, ss.imagePath) : ss.imagePath;
918
- }
919
- else if (ss.imageBody && ss.imageContentType) {
920
- imgSrc = `data:${ss.imageContentType};base64,${ss.imageBody}`;
921
- }
921
+ const imgSrc = resolveStepImageSrc(ss, outputDir);
922
922
  // For the audit tool, render a structured report instead of raw JSON.
923
923
  const isAudit = ss.toolName === 'audit' && ss.metadata && 'pageLoad' in ss.metadata;
924
924
  let detailBlock = '';
@@ -946,12 +946,10 @@ function renderFilmstripStep(ss, outputDir) {
946
946
  const expandId = `step-expand-${uid()}`;
947
947
  let html = `<div class="filmstrip-step${hasExpandable ? ' expandable' : ''}">`;
948
948
  html += `<div class="filmstrip-header">`;
949
+ html += `<span class="filmstrip-chevron" aria-hidden="true">&#9656;</span>`;
949
950
  html += statusIcon;
950
951
  html += `<span class="filmstrip-tool">${esc(ss.toolName)}</span>`;
951
952
  html += `<span class="filmstrip-duration">${durationStr}</span>`;
952
- if (hasExpandable) {
953
- html += `<span class="filmstrip-chevron">&#9656;</span>`;
954
- }
955
953
  html += `</div>`;
956
954
  if (ss.summary) {
957
955
  html += `<div class="filmstrip-summary">${esc(ss.summary)}</div>`;
@@ -1757,12 +1755,13 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1757
1755
  .filmstrip-duration{font-size:11px;color:var(--text-dim);font-family:var(--mono)}
1758
1756
  .filmstrip-step.expandable{cursor:pointer}
1759
1757
  .filmstrip-step.expandable:hover{background:var(--bg)}
1760
- .filmstrip-chevron{margin-left:auto;font-size:11px;color:var(--text-dim);transition:transform .15s}
1758
+ .filmstrip-chevron,.native-step-chevron{font-size:13px;color:var(--text-muted);width:14px;display:inline-flex;justify-content:center;flex-shrink:0;transition:transform .15s}
1759
+ .filmstrip-step:not(.expandable) .filmstrip-chevron,.filmstrip-step:not(.expandable) .native-step-chevron{visibility:hidden}
1761
1760
  .filmstrip-step.open .filmstrip-chevron{transform:rotate(90deg)}
1762
- .filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;padding-left:22px}
1761
+ .filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;padding-left:44px}
1763
1762
  .step-status-ok{color:var(--green);font-size:12px;font-weight:bold}
1764
1763
  .step-status-fail{color:var(--red);font-size:12px;font-weight:bold}
1765
- .filmstrip-detail{display:none;padding:8px 0 4px 22px;flex-direction:row;gap:12px;align-items:flex-start}
1764
+ .filmstrip-detail{display:none;padding:8px 0 4px 44px;flex-direction:row;gap:12px;align-items:flex-start}
1766
1765
  .filmstrip-step.open .filmstrip-detail{display:flex}
1767
1766
  .filmstrip-detail>a{flex-shrink:0;max-width:50%}
1768
1767
  .filmstrip-detail>.step-json-wrap{flex:1;min-width:0}
@@ -1822,10 +1821,9 @@ details.native-step>summary::-webkit-details-marker{display:none}
1822
1821
  .native-step-badge--expect{background:rgba(99,102,241,.12);color:#818cf8}
1823
1822
  .native-step-badge--test\.step{background:rgba(16,185,129,.10);color:#34d399}
1824
1823
  .native-step-location{font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-left:auto;flex-shrink:0;white-space:nowrap}
1825
- .native-step-chevron{font-size:10px;color:var(--text-dim);flex-shrink:0;transition:transform .12s;display:inline-block;margin-left:4px}
1826
1824
  details.native-step[open]>summary .native-step-chevron{transform:rotate(90deg)}
1827
- .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)}
1828
- .native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px 22px;overflow:hidden}
1825
+ .native-step-error{font-size:11px;font-family:var(--mono);padding:4px 0 2px 44px;margin:0;white-space:pre-wrap;word-break:break-word;color:var(--text-muted)}
1826
+ .native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px 44px;overflow:hidden}
1829
1827
  .native-step-children{display:flex;flex-direction:column;margin:4px 0 0 10px;border-left:1px solid var(--border-subtle);padding-left:8px}
1830
1828
  .native-step-children>.filmstrip-step{padding-left:8px}
1831
1829
 
@@ -1839,7 +1837,7 @@ details.ai-invocation>summary::-webkit-details-marker{display:none}
1839
1837
  .ai-invocation-badge--locate{background:rgba(59,130,246,.12);color:#60a5fa}
1840
1838
  .ai-cached-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0;background:rgba(245,158,11,.12);color:#fbbf24}
1841
1839
  details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)}
1842
- .ai-assert-steps{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:6px 0 2px 22px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:240px;overflow-y:auto}
1840
+ .ai-assert-steps{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:6px 0 2px 44px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:240px;overflow-y:auto}
1843
1841
  .snippet-line{display:flex;padding:1px 8px;white-space:pre}
1844
1842
  .snippet-line--target{background:rgba(239,68,68,.10)}
1845
1843
  .snippet-linenum{color:var(--text-dim);min-width:40px;user-select:none}
@@ -1961,7 +1959,7 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
1961
1959
  /* Lightbox */
1962
1960
  .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}
1963
1961
  .lightbox.open{display:flex}
1964
- .lightbox img{max-width:95vw;max-height:95vh;border-radius:var(--radius)}
1962
+ .lightbox img{max-width:85vw;max-height:85vh;border-radius:var(--radius)}
1965
1963
 
1966
1964
  /* Print */
1967
1965
  @media print{
@@ -2024,9 +2022,12 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2024
2022
  document.addEventListener('click',function(e){
2025
2023
  // Close lightbox when clicking its backdrop
2026
2024
  if(e.target.closest('[data-lightbox]')&&!e.target.closest('#lightboxImg')){closeLightbox();return}
2027
- // Open lightbox for screenshot images
2028
- var img=e.target.closest('.screenshot');
2029
- if(img){
2025
+ // Open lightbox for screenshot images. Skip on modified clicks so the
2026
+ // wrapping <a target="_blank"> still handles cmd/ctrl/shift/middle-click
2027
+ // (open-in-new-tab). Right-click goes through the contextmenu event and
2028
+ // is never intercepted here, so "Save image as..." keeps working.
2029
+ var img=e.target.closest('.screenshot, .step-screenshot');
2030
+ if(img&&e.button===0&&!e.metaKey&&!e.ctrlKey&&!e.shiftKey&&!e.altKey){
2030
2031
  e.preventDefault();e.stopPropagation();
2031
2032
  var src=img.closest('a')?img.closest('a').href:img.src;
2032
2033
  document.getElementById('lightboxImg').src=src;
@@ -2053,9 +2054,22 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2053
2054
  // Test bar block click — scroll to and highlight the target test card
2054
2055
  var barBlock=e.target.closest('.test-bar-block[data-target]');
2055
2056
  if(barBlock){var tid=barBlock.getAttribute('data-target');var card=document.getElementById(tid);if(card){var tc=card.closest('.test-card');if(tc){tc.classList.add('expanded');tc.scrollIntoView({behavior:'smooth',block:'center'});tc.style.outline='2px solid var(--accent)';setTimeout(function(){tc.style.outline=''},1500)}}return}
2056
- // Filmstrip step expand (skip if clicking a link inside)
2057
+ // Filmstrip step expand (skip if clicking a link inside).
2058
+ // <div>-based steps: toggle the open class on any click in the wrapper.
2059
+ // <details>-based steps: <summary> clicks are handled natively; padding
2060
+ // clicks (which hit the <details> element directly, not a child) need
2061
+ // a manual toggle so the entire visible row is the click target.
2062
+ // Body-content clicks are intentionally left alone so snippet text
2063
+ // stays selectable.
2057
2064
  var step=e.target.closest('.filmstrip-step.expandable');
2058
- if(step&&!e.target.closest('a')&&!e.target.closest('.audit-check')){step.classList.toggle('open');return}
2065
+ if(step&&!e.target.closest('a')&&!e.target.closest('.audit-check')){
2066
+ if(step.tagName==='DETAILS'){
2067
+ if(e.target===step){step.open=!step.open}
2068
+ }else{
2069
+ step.classList.toggle('open');
2070
+ }
2071
+ return;
2072
+ }
2059
2073
  // Audit check row expand
2060
2074
  var auditCheck=e.target.closest('.audit-check.expandable');
2061
2075
  if(auditCheck){auditCheck.classList.toggle('open');return}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.36.5",
3
+ "version": "5.36.6",
4
4
  "description": "Create browser automations with an LLM agent and replay them as Playwright scripts.",
5
5
  "main": "dist/main.js",
6
6
  "module": "dist/esm/main.js",