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.
- package/dist/esm/lib/test/testExtension.js +23 -5
- package/dist/esm/managers/SuitesManager.d.ts +11 -3
- package/dist/esm/managers/SuitesManager.js +17 -12
- package/dist/esm/reporter/buildReport.js +9 -2
- package/dist/esm/reporter/render.js +49 -35
- package/dist/lib/test/testExtension.js +23 -5
- package/dist/managers/SuitesManager.d.ts +11 -3
- package/dist/managers/SuitesManager.js +17 -12
- package/dist/reporter/buildReport.js +9 -2
- package/dist/reporter/render.js +49 -35
- package/package.json +1 -1
|
@@ -342,7 +342,7 @@ exports.test = test_1.test.extend({
|
|
|
342
342
|
return;
|
|
343
343
|
}
|
|
344
344
|
if (initialActive === 0) {
|
|
345
|
-
Logger_1.appLogger.
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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 (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
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="
|
|
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">▸</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">▸</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">▸</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">▸</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">✓</span>'
|
|
914
920
|
: '<span class="step-status-fail">✗</span>';
|
|
915
|
-
|
|
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">▸</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">▸</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{
|
|
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:
|
|
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
|
|
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
|
|
1828
|
-
.native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px
|
|
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
|
|
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:
|
|
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
|
-
|
|
2029
|
-
|
|
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')){
|
|
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.
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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 (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
82
|
-
|
|
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.
|
package/dist/reporter/render.js
CHANGED
|
@@ -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
|
-
|
|
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="
|
|
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">▸</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">▸</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">▸</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">▸</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">✓</span>'
|
|
914
920
|
: '<span class="step-status-fail">✗</span>';
|
|
915
|
-
|
|
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">▸</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">▸</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{
|
|
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:
|
|
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
|
|
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
|
|
1828
|
-
.native-step-snippet{font-size:11px;font-family:var(--mono);margin:4px 0 2px
|
|
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
|
|
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:
|
|
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
|
-
|
|
2029
|
-
|
|
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')){
|
|
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}
|