donobu 5.36.4 → 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.
@@ -342,7 +342,7 @@ exports.test = test_1.test.extend({
342
342
  return;
343
343
  }
344
344
  if (initialActive === 0) {
345
- Logger_1.appLogger.info('donobuFileUploadDrainGuard: no pending file uploads at end of test ' +
345
+ Logger_1.appLogger.debug('donobuFileUploadDrainGuard: no pending file uploads at end of test ' +
346
346
  'session; worker is exiting.');
347
347
  return;
348
348
  }
@@ -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 {
@@ -19,9 +19,17 @@ export declare class SuitesManager {
19
19
  /**
20
20
  * Delete a suite from all persistence layers.
21
21
  *
22
- * After deleting, orphans any tests that belong to this suite by setting
23
- * their suiteId to null. This mirrors the DB-level ON DELETE SET NULL
24
- * behavior for non-DB persistence layers (Volatile, S3, GCS).
22
+ * Orphans tests that belong to the suite first, then deletes the suite
23
+ * row. The orphan pass must run before the suite delete: SQLite has an
24
+ * ON DELETE SET NULL FK on test_metadata.suite_id, which clears the
25
+ * column synchronously inside DELETE FROM suite_metadata. If we deleted
26
+ * first, the subsequent getTests({ suiteId }) filter would find zero
27
+ * rows in the SQLite layer (column already null) and skip the JSON-blob
28
+ * rewrite, leaving each affected test's `metadata.suiteId` pointed at a
29
+ * suite that no longer exists. Orphaning first rewrites both the SQL
30
+ * column and the JSON blob via updateTest, so the FK cascade is then a
31
+ * no-op. Non-DB layers (Volatile, S3, GCS) have no FK and rely on this
32
+ * pass for the cascade behavior in either ordering.
25
33
  */
26
34
  deleteSuite(suiteId: string): Promise<void>;
27
35
  }
@@ -103,27 +103,32 @@ class SuitesManager {
103
103
  /**
104
104
  * Delete a suite from all persistence layers.
105
105
  *
106
- * After deleting, orphans any tests that belong to this suite by setting
107
- * their suiteId to null. This mirrors the DB-level ON DELETE SET NULL
108
- * behavior for non-DB persistence layers (Volatile, S3, GCS).
106
+ * Orphans tests that belong to the suite first, then deletes the suite
107
+ * row. The orphan pass must run before the suite delete: SQLite has an
108
+ * ON DELETE SET NULL FK on test_metadata.suite_id, which clears the
109
+ * column synchronously inside DELETE FROM suite_metadata. If we deleted
110
+ * first, the subsequent getTests({ suiteId }) filter would find zero
111
+ * rows in the SQLite layer (column already null) and skip the JSON-blob
112
+ * rewrite, leaving each affected test's `metadata.suiteId` pointed at a
113
+ * suite that no longer exists. Orphaning first rewrites both the SQL
114
+ * column and the JSON blob via updateTest, so the FK cascade is then a
115
+ * no-op. Non-DB layers (Volatile, S3, GCS) have no FK and rely on this
116
+ * pass for the cascade behavior in either ordering.
109
117
  */
110
118
  async deleteSuite(suiteId) {
111
119
  for (const { key, persistence, } of await this.suitesPersistenceRegistry.getEntries()) {
112
120
  try {
113
- await persistence.deleteSuite(suiteId);
114
- // Orphan tests in this layer after successfully deleting the suite.
115
- // This mirrors the DB-level ON DELETE SET NULL for non-DB layers.
116
121
  // Pair by key — the suites and tests registries can have different
117
122
  // sets of layers (e.g. plugin-only suites persistence) so positional
118
123
  // indexing isn't safe.
119
124
  const testsPersistence = await this.testsPersistenceRegistry.getByKey(key);
120
- if (!testsPersistence) {
121
- continue;
122
- }
123
- const testsResult = await testsPersistence.getTests({ suiteId });
124
- for (const test of testsResult.items) {
125
- await testsPersistence.updateTest({ ...test, suiteId: null });
125
+ if (testsPersistence) {
126
+ const testsResult = await testsPersistence.getTests({ suiteId });
127
+ for (const test of testsResult.items) {
128
+ await testsPersistence.updateTest({ ...test, suiteId: null });
129
+ }
126
130
  }
131
+ await persistence.deleteSuite(suiteId);
127
132
  }
128
133
  catch {
129
134
  // Ignore errors from layers that don't have this suite.
@@ -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}
@@ -342,7 +342,7 @@ exports.test = test_1.test.extend({
342
342
  return;
343
343
  }
344
344
  if (initialActive === 0) {
345
- Logger_1.appLogger.info('donobuFileUploadDrainGuard: no pending file uploads at end of test ' +
345
+ Logger_1.appLogger.debug('donobuFileUploadDrainGuard: no pending file uploads at end of test ' +
346
346
  'session; worker is exiting.');
347
347
  return;
348
348
  }
@@ -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 {
@@ -19,9 +19,17 @@ export declare class SuitesManager {
19
19
  /**
20
20
  * Delete a suite from all persistence layers.
21
21
  *
22
- * After deleting, orphans any tests that belong to this suite by setting
23
- * their suiteId to null. This mirrors the DB-level ON DELETE SET NULL
24
- * behavior for non-DB persistence layers (Volatile, S3, GCS).
22
+ * Orphans tests that belong to the suite first, then deletes the suite
23
+ * row. The orphan pass must run before the suite delete: SQLite has an
24
+ * ON DELETE SET NULL FK on test_metadata.suite_id, which clears the
25
+ * column synchronously inside DELETE FROM suite_metadata. If we deleted
26
+ * first, the subsequent getTests({ suiteId }) filter would find zero
27
+ * rows in the SQLite layer (column already null) and skip the JSON-blob
28
+ * rewrite, leaving each affected test's `metadata.suiteId` pointed at a
29
+ * suite that no longer exists. Orphaning first rewrites both the SQL
30
+ * column and the JSON blob via updateTest, so the FK cascade is then a
31
+ * no-op. Non-DB layers (Volatile, S3, GCS) have no FK and rely on this
32
+ * pass for the cascade behavior in either ordering.
25
33
  */
26
34
  deleteSuite(suiteId: string): Promise<void>;
27
35
  }
@@ -103,27 +103,32 @@ class SuitesManager {
103
103
  /**
104
104
  * Delete a suite from all persistence layers.
105
105
  *
106
- * After deleting, orphans any tests that belong to this suite by setting
107
- * their suiteId to null. This mirrors the DB-level ON DELETE SET NULL
108
- * behavior for non-DB persistence layers (Volatile, S3, GCS).
106
+ * Orphans tests that belong to the suite first, then deletes the suite
107
+ * row. The orphan pass must run before the suite delete: SQLite has an
108
+ * ON DELETE SET NULL FK on test_metadata.suite_id, which clears the
109
+ * column synchronously inside DELETE FROM suite_metadata. If we deleted
110
+ * first, the subsequent getTests({ suiteId }) filter would find zero
111
+ * rows in the SQLite layer (column already null) and skip the JSON-blob
112
+ * rewrite, leaving each affected test's `metadata.suiteId` pointed at a
113
+ * suite that no longer exists. Orphaning first rewrites both the SQL
114
+ * column and the JSON blob via updateTest, so the FK cascade is then a
115
+ * no-op. Non-DB layers (Volatile, S3, GCS) have no FK and rely on this
116
+ * pass for the cascade behavior in either ordering.
109
117
  */
110
118
  async deleteSuite(suiteId) {
111
119
  for (const { key, persistence, } of await this.suitesPersistenceRegistry.getEntries()) {
112
120
  try {
113
- await persistence.deleteSuite(suiteId);
114
- // Orphan tests in this layer after successfully deleting the suite.
115
- // This mirrors the DB-level ON DELETE SET NULL for non-DB layers.
116
121
  // Pair by key — the suites and tests registries can have different
117
122
  // sets of layers (e.g. plugin-only suites persistence) so positional
118
123
  // indexing isn't safe.
119
124
  const testsPersistence = await this.testsPersistenceRegistry.getByKey(key);
120
- if (!testsPersistence) {
121
- continue;
122
- }
123
- const testsResult = await testsPersistence.getTests({ suiteId });
124
- for (const test of testsResult.items) {
125
- await testsPersistence.updateTest({ ...test, suiteId: null });
125
+ if (testsPersistence) {
126
+ const testsResult = await testsPersistence.getTests({ suiteId });
127
+ for (const test of testsResult.items) {
128
+ await testsPersistence.updateTest({ ...test, suiteId: null });
129
+ }
126
130
  }
131
+ await persistence.deleteSuite(suiteId);
127
132
  }
128
133
  catch {
129
134
  // Ignore errors from layers that don't have this suite.
@@ -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.4",
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",