donobu 5.41.4 → 5.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,10 @@ function buildDonobuReport(resultsByTest, rootDir) {
48
48
  const testEntries = tests.map((test) => {
49
49
  const results = resultsByTest.get(test) ?? [];
50
50
  return {
51
+ // Playwright's stable per-(file, project, title) ID — used by the
52
+ // merge step's `byId` index and by the HTML renderer for permalink
53
+ // anchors (`index.html#?testId=<id>`) and the per-test redirect stubs.
54
+ testId: test.id,
51
55
  annotations: test.annotations,
52
56
  tags: test.tags,
53
57
  projectName: getProjectName(test),
@@ -51,5 +51,16 @@ export default class DonobuHtmlReporter implements Reporter {
51
51
  onTestEnd(test: TestCase, result: TestResult): void;
52
52
  onEnd(_result: FullResult): Promise<void>;
53
53
  printsToStdio(): boolean;
54
+ /**
55
+ * Drop a tiny redirect `index.html` into each Playwright-managed per-test
56
+ * directory under `outputDir`. The stub points back at the combined report's
57
+ * `#?testId=<id>` deep link, giving every test a stable URL inside its own
58
+ * directory without duplicating any of the rendered HTML.
59
+ *
60
+ * Directories are discovered from `dirname(attachment.path)` — Donobu does
61
+ * not invent any directory naming or layout. Tests with no attachments (and
62
+ * therefore no Playwright-created directory) get no stub.
63
+ */
64
+ private writePerTestStubs;
54
65
  }
55
66
  //# sourceMappingURL=html.d.ts.map
@@ -82,10 +82,65 @@ class DonobuHtmlReporter {
82
82
  (0, fs_1.mkdirSync)(outputDir, { recursive: true });
83
83
  (0, fs_1.writeFileSync)(outputFile, html, 'utf8');
84
84
  Logger_1.appLogger.info(`Donobu report written to ${outputFile}`);
85
+ this.writePerTestStubs(outputFile, outputDir);
85
86
  }
86
87
  printsToStdio() {
87
88
  return false;
88
89
  }
90
+ /**
91
+ * Drop a tiny redirect `index.html` into each Playwright-managed per-test
92
+ * directory under `outputDir`. The stub points back at the combined report's
93
+ * `#?testId=<id>` deep link, giving every test a stable URL inside its own
94
+ * directory without duplicating any of the rendered HTML.
95
+ *
96
+ * Directories are discovered from `dirname(attachment.path)` — Donobu does
97
+ * not invent any directory naming or layout. Tests with no attachments (and
98
+ * therefore no Playwright-created directory) get no stub.
99
+ */
100
+ writePerTestStubs(outputFile, outputDir) {
101
+ for (const [test, results] of this.resultsByTest) {
102
+ if (!test.id) {
103
+ continue;
104
+ }
105
+ const testDirs = new Set();
106
+ for (const result of results) {
107
+ for (const att of result.attachments) {
108
+ if (!att.path) {
109
+ continue;
110
+ }
111
+ const attDir = (0, path_1.dirname)((0, path_1.resolve)(att.path));
112
+ const rel = (0, path_1.relative)(outputDir, attDir);
113
+ if (!rel || rel.startsWith('..')) {
114
+ // Attachment lives outside the report's output directory — skip;
115
+ // we shouldn't be writing into arbitrary paths.
116
+ continue;
117
+ }
118
+ testDirs.add(attDir);
119
+ }
120
+ }
121
+ if (testDirs.size === 0) {
122
+ continue;
123
+ }
124
+ const fileBase = test.location.file.split('/').pop() ?? test.location.file;
125
+ const title = `${fileBase} › ${test.title}`;
126
+ for (const testDir of testDirs) {
127
+ const stubPath = (0, path_1.resolve)(testDir, 'index.html');
128
+ const relPathToReport = (0, path_1.relative)(testDir, outputFile);
129
+ const stub = (0, render_1.renderPerTestStub)({
130
+ testId: test.id,
131
+ title,
132
+ relPathToReport,
133
+ });
134
+ try {
135
+ (0, fs_1.mkdirSync)(testDir, { recursive: true });
136
+ (0, fs_1.writeFileSync)(stubPath, stub, 'utf8');
137
+ }
138
+ catch (err) {
139
+ Logger_1.appLogger.warn(`Failed to write per-test redirect stub at ${stubPath}: ${err.message}`);
140
+ }
141
+ }
142
+ }
143
+ }
89
144
  }
90
145
  exports.default = DonobuHtmlReporter;
91
146
  //# sourceMappingURL=html.js.map
@@ -140,5 +140,20 @@ export interface TriageData {
140
140
  }
141
141
  export declare function loadTriageData(triageDir: string): TriageData;
142
142
  export declare function renderHtml(report: DonobuReport, triage: TriageData, outputDir: string | null): string;
143
+ /**
144
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
145
+ * per-test directory under `test-results/`. The stub bounces straight to the
146
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
147
+ * visible fallback link, so it works with or without JS, online or `file://`.
148
+ *
149
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
150
+ * directories — the caller in `html.ts` only writes this file into directories
151
+ * Playwright already created for the test's attachments.
152
+ */
153
+ export declare function renderPerTestStub(params: {
154
+ testId: string;
155
+ title: string;
156
+ relPathToReport: string;
157
+ }): string;
143
158
  export {};
144
159
  //# sourceMappingURL=render.d.ts.map
@@ -10,6 +10,7 @@
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.loadTriageData = loadTriageData;
12
12
  exports.renderHtml = renderHtml;
13
+ exports.renderPerTestStub = renderPerTestStub;
13
14
  const fs_1 = require("fs");
14
15
  const path_1 = require("path");
15
16
  const ansi_1 = require("../utils/ansi");
@@ -366,6 +367,7 @@ function extractTests(jsonData) {
366
367
  tests.push({
367
368
  file: suite.file,
368
369
  specTitle: spec.title,
370
+ testId: typeof test.testId === 'string' ? test.testId : '',
369
371
  status,
370
372
  isSelfHealed,
371
373
  objective: objectiveAnnotation?.description ?? null,
@@ -722,7 +724,7 @@ function renderAiInvocation(inv, childrenHtml) {
722
724
  miss: 'cache · miss',
723
725
  };
724
726
  const cacheTitle = {
725
- hit: 'Replayed from the page-AI cache. No AI call this run.',
727
+ hit: 'Replayed from the page-AI cache; no AI used for this step.',
726
728
  stored: 'Live AI run; the resulting locators/steps were recorded to the page-AI cache. The next run can replay them without calling the AI.',
727
729
  miss: "Live AI run; nothing was recorded to the page-AI cache. The next run will hit the AI again. For asserts, this typically means the AI's structured Playwright locators didn't reproduce its screenshot verdict.",
728
730
  };
@@ -1530,8 +1532,10 @@ function renderHtml(report, triage, outputDir) {
1530
1532
  // Group by file
1531
1533
  const uniqueFiles = new Set(tests.map((t) => t.file));
1532
1534
  // --- Build HTML sections ---
1533
- // Pre-assign stable IDs for each test (used by both bar blocks and cards)
1534
- const testIds = tests.map(() => `t-${uid()}`);
1535
+ // Per-card IDs used by the test-detail div, bar-block `data-target`, and the
1536
+ // outer `.test-card` anchor that `#?testId=<id>` deep links target. Sourced
1537
+ // from Playwright's stable `TestCase.id`.
1538
+ const testIds = tests.map((t) => `test-${t.testId}`);
1535
1539
  // Build test bar blocks (one square per test, ordered by status, clickable)
1536
1540
  const statusOrder = [
1537
1541
  'passed',
@@ -1674,7 +1678,7 @@ function renderHtml(report, triage, outputDir) {
1674
1678
  ? `<div class="flow-id-detail"><span class="detail-label">Flow ID</span><span class="flow-id-value">${esc(test.flowId)}<button class="copy-flow-id" data-flow-id="${esc(test.flowId)}" title="Copy flow ID"><svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button></span></div>`
1675
1679
  : '';
1676
1680
  testSectionsHtml += `
1677
- <div class="test-card ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" data-status="${test.status}" ${hasDetails ? `data-detail="${testId}"` : ''}>
1681
+ <div class="test-card ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" id="${testId}" data-status="${test.status}" data-tags="${esc(JSON.stringify(test.tags))}" ${hasDetails ? `data-detail="${testId}"` : ''}>
1678
1682
  <div class="test-summary">
1679
1683
  ${chevron}
1680
1684
  <span class="status-dot" style="background:${sc.color}" title="${sc.label}"></span>
@@ -1686,7 +1690,7 @@ function renderHtml(report, triage, outputDir) {
1686
1690
  ${totalStepCount > 0 ? `<span class="test-step-count" title="${totalStepCount} steps">${totalStepCount} steps</span>` : ''}
1687
1691
  <span class="test-duration">${fmtDuration(totalTestDuration)}</span>
1688
1692
  </div>
1689
- ${hasDetails ? `<div class="test-detail" id="${testId}">${flowIdDetailHtml}${detailsHtml}</div>` : ''}
1693
+ ${hasDetails ? `<div class="test-detail" id="detail-${testId}">${flowIdDetailHtml}${detailsHtml}</div>` : ''}
1690
1694
  </div>`;
1691
1695
  }
1692
1696
  const mergedBanner = isMergedReport
@@ -1768,6 +1772,32 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1768
1772
  .clear-filter:hover{background:var(--surface-raised);border-color:var(--text-dim);color:var(--text)}
1769
1773
  .clear-filter.visible{display:flex}
1770
1774
 
1775
+ /* Tag filter controls: + button opens a menu of available tags; selecting one
1776
+ * adds a removable chip. Multiple chips combine with logical AND. */
1777
+ .tag-filter-controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
1778
+ .tag-filter-trigger-wrap{position:relative;display:inline-flex}
1779
+ .add-tag-filter{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);height:28px;padding:0 12px;border-radius:var(--radius);cursor:pointer;font-size:12px;font-weight:600;font-family:inherit;display:inline-flex;align-items:center;gap:4px;flex-shrink:0;transition:all .2s;line-height:1}
1780
+ .add-tag-filter .add-tag-plus{font-size:15px;line-height:1}
1781
+ .add-tag-filter:hover{background:var(--surface-raised);border-color:var(--text-dim);color:var(--text)}
1782
+ .add-tag-filter.active{background:var(--accent);border-color:var(--accent);color:#fff}
1783
+ .tag-menu{position:absolute;top:calc(100% + 6px);left:0;min-width:200px;max-width:320px;max-height:280px;overflow-y:auto;background:var(--surface-raised);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:20;padding:4px;display:none}
1784
+ .tag-menu:not([hidden]){display:block}
1785
+ .tag-menu-item{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;font-size:12px;font-family:var(--mono);color:var(--text);background:transparent;border:none;border-radius:4px;cursor:pointer;text-align:left;width:100%;transition:background .15s}
1786
+ .tag-menu-item:hover{background:var(--surface)}
1787
+ .tag-menu-item .tag-menu-count{color:var(--text-muted);font-size:11px;font-family:var(--mono)}
1788
+ .tag-menu-empty{padding:8px 10px;font-size:12px;color:var(--text-muted);font-style:italic}
1789
+ .active-tag-filters{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
1790
+ .tag-chip{display:inline-flex;align-items:center;gap:6px;background:rgba(255,127,58,.12);border:1px solid rgba(255,127,58,.3);color:var(--accent);font-size:11px;font-family:var(--mono);padding:3px 4px 3px 8px;border-radius:4px}
1791
+ .tag-chip-remove{background:transparent;border:none;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:0 4px;font-family:inherit;opacity:.7;transition:opacity .15s}
1792
+ .tag-chip-remove:hover{opacity:1}
1793
+
1794
+ /* Total of cards visible under the currently composed filters (status + tags).
1795
+ * The stat-pill counts always reflect totals; this disambiguates when filters
1796
+ * intersect. Hidden until any filter is active. */
1797
+ .match-count{display:none;align-items:center;font-size:12px;color:var(--text-muted);font-family:var(--mono);padding:0 8px;height:28px;flex-shrink:0}
1798
+ .match-count.visible{display:inline-flex}
1799
+ .match-count-value{color:var(--text);font-weight:600;margin-left:6px}
1800
+
1771
1801
  /* Test cards */
1772
1802
  .test-card{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
1773
1803
  .test-card.failed,.test-card.timedout,.test-card.interrupted{border-color:rgba(239,68,68,.2)}
@@ -2104,7 +2134,15 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2104
2134
  <div class="test-bar">${testBarHtml}</div>
2105
2135
  <div class="summary-stats">
2106
2136
  <div class="stat-pills">${statPillsHtml}</div>
2107
- <button class="clear-filter" data-clear-filter>&#x2716; Clear Filter</button>
2137
+ <div class="tag-filter-controls" data-tag-filter-controls hidden>
2138
+ <div class="tag-filter-trigger-wrap">
2139
+ <button class="add-tag-filter" data-add-tag-filter title="Filter by tag"><span class="add-tag-plus">+</span> Tag</button>
2140
+ <div class="tag-menu" data-tag-menu hidden></div>
2141
+ </div>
2142
+ <div class="active-tag-filters" data-active-tag-filters></div>
2143
+ </div>
2144
+ <span class="match-count" data-match-count>Matches:<span class="match-count-value" data-match-count-value>0</span></span>
2145
+ <button class="clear-filter" data-clear-filter>&#x2716; Clear Filters</button>
2108
2146
  </div>
2109
2147
  </div>
2110
2148
 
@@ -2122,20 +2160,113 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2122
2160
 
2123
2161
  <script>
2124
2162
  (function(){
2125
- var activeFilter=null;
2126
- function filterByStatus(s){
2127
- if(activeFilter===s){clearFilter();return}
2128
- activeFilter=s;
2129
- document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===s)});
2130
- document.querySelector('.clear-filter').classList.add('visible');
2131
- document.querySelectorAll('.test-card').forEach(function(r){r.classList.toggle('hidden-by-filter',r.getAttribute('data-status')!==s)});
2163
+ // Filters compose: a card is visible iff its status matches the active
2164
+ // status filter (if any) AND it carries every tag in activeTags. The two
2165
+ // dimensions are independent; "Clear Filter" wipes both.
2166
+ var activeStatus=null;
2167
+ var activeTags=new Set();
2168
+ var allTags=[];
2169
+
2170
+ function cardTags(card){var raw=card.getAttribute('data-tags');if(!raw)return [];try{var v=JSON.parse(raw);return Array.isArray(v)?v:[]}catch(_){return []}}
2171
+ function tagCount(t){var n=0;document.querySelectorAll('.test-card').forEach(function(c){if(cardTags(c).indexOf(t)!==-1)n++});return n}
2172
+
2173
+ function applyFilters(){
2174
+ var anyActive=activeStatus!==null||activeTags.size>0;
2175
+ document.querySelector('.clear-filter').classList.toggle('visible',anyActive);
2176
+ var visible=0;
2177
+ document.querySelectorAll('.test-card').forEach(function(card){
2178
+ var statusOk=activeStatus===null||card.getAttribute('data-status')===activeStatus;
2179
+ var tagsOk=true;
2180
+ if(activeTags.size>0){
2181
+ var t=cardTags(card);
2182
+ activeTags.forEach(function(want){if(t.indexOf(want)===-1)tagsOk=false});
2183
+ }
2184
+ var hide=!(statusOk&&tagsOk);
2185
+ card.classList.toggle('hidden-by-filter',hide);
2186
+ if(!hide)visible++;
2187
+ });
2188
+ var mc=document.querySelector('[data-match-count]');
2189
+ if(mc){
2190
+ mc.classList.toggle('visible',anyActive);
2191
+ var mv=mc.querySelector('[data-match-count-value]');
2192
+ if(mv)mv.textContent=visible;
2193
+ }
2194
+ syncFiltersToUrl();
2195
+ }
2196
+
2197
+ // Reflect the active filter state in the URL query string so the current
2198
+ // view is shareable. replaceState (not pushState) keeps the back button
2199
+ // useful; the existing #?testId=<id> hash is preserved.
2200
+ function syncFiltersToUrl(){
2201
+ var p=new URLSearchParams();
2202
+ if(activeStatus)p.set('status',activeStatus);
2203
+ activeTags.forEach(function(t){p.append('tag',t)});
2204
+ var qs=p.toString();
2205
+ var next=location.pathname+(qs?'?'+qs:'')+(location.hash||'');
2206
+ if(next!==location.pathname+location.search+location.hash){
2207
+ history.replaceState(null,'',next);
2208
+ }
2209
+ }
2210
+
2211
+ function toggleStatus(s){
2212
+ activeStatus=(activeStatus===s)?null:s;
2213
+ document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===activeStatus)});
2214
+ applyFilters();
2215
+ }
2216
+
2217
+ function renderActiveChips(){
2218
+ var c=document.querySelector('[data-active-tag-filters]');
2219
+ if(!c)return;
2220
+ c.innerHTML='';
2221
+ activeTags.forEach(function(t){
2222
+ var chip=document.createElement('span');chip.className='tag-chip';
2223
+ var label=document.createElement('span');label.textContent=t;
2224
+ var btn=document.createElement('button');btn.className='tag-chip-remove';btn.setAttribute('data-remove-tag',t);btn.setAttribute('title','Remove filter');btn.textContent='×';
2225
+ chip.appendChild(label);chip.appendChild(btn);
2226
+ c.appendChild(chip);
2227
+ });
2132
2228
  }
2133
- function clearFilter(){
2134
- activeFilter=null;
2229
+ function addTag(t){if(!t||activeTags.has(t))return;activeTags.add(t);renderActiveChips();applyFilters()}
2230
+ function removeTag(t){if(!activeTags.delete(t))return;renderActiveChips();applyFilters()}
2231
+
2232
+ function openTagMenu(){
2233
+ var menu=document.querySelector('[data-tag-menu]');
2234
+ if(!menu)return;
2235
+ var trigger=document.querySelector('[data-add-tag-filter]');
2236
+ menu.innerHTML='';
2237
+ var available=allTags.filter(function(t){return !activeTags.has(t)});
2238
+ if(available.length===0){
2239
+ var empty=document.createElement('div');empty.className='tag-menu-empty';
2240
+ empty.textContent=allTags.length?'All tags selected':'No tags';
2241
+ menu.appendChild(empty);
2242
+ }else{
2243
+ available.forEach(function(t){
2244
+ var item=document.createElement('button');item.className='tag-menu-item';item.setAttribute('data-tag-menu-item',t);
2245
+ var label=document.createElement('span');label.textContent=t;
2246
+ var count=document.createElement('span');count.className='tag-menu-count';count.textContent=tagCount(t);
2247
+ item.appendChild(label);item.appendChild(count);
2248
+ menu.appendChild(item);
2249
+ });
2250
+ }
2251
+ menu.hidden=false;
2252
+ if(trigger)trigger.classList.add('active');
2253
+ }
2254
+ function closeTagMenu(){
2255
+ var menu=document.querySelector('[data-tag-menu]');var trigger=document.querySelector('[data-add-tag-filter]');
2256
+ if(menu)menu.hidden=true;
2257
+ if(trigger)trigger.classList.remove('active');
2258
+ }
2259
+ function tagMenuOpen(){var m=document.querySelector('[data-tag-menu]');return !!(m&&!m.hidden)}
2260
+
2261
+ function clearAllFilters(){
2262
+ activeStatus=null;
2263
+ activeTags.clear();
2135
2264
  document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.remove('active')});
2136
- document.querySelector('.clear-filter').classList.remove('visible');
2137
- document.querySelectorAll('.test-card').forEach(function(r){r.classList.remove('hidden-by-filter')});
2265
+ renderActiveChips();
2266
+ closeTagMenu();
2267
+ applyFilters();
2138
2268
  }
2269
+
2139
2270
  function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
2140
2271
 
2141
2272
  document.addEventListener('click',function(e){
@@ -2165,14 +2296,28 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2165
2296
  if(flowBtn){e.stopPropagation();navigator.clipboard.writeText(flowBtn.getAttribute('data-flow-id'));var copySvg=flowBtn.innerHTML;flowBtn.innerHTML='<svg class="check-icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>';setTimeout(function(){flowBtn.innerHTML=copySvg},2000);return}
2166
2297
  var jsonBtn=e.target.closest('.copy-json');
2167
2298
  if(jsonBtn){e.stopPropagation();var pre=jsonBtn.closest('.step-json-wrap').querySelector('.step-json');navigator.clipboard.writeText(pre.textContent);var s=jsonBtn.innerHTML;jsonBtn.innerHTML='<svg class="check-icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>';setTimeout(function(){jsonBtn.innerHTML=s},2000);return}
2299
+ // Tag filter: add (+ button toggles the menu) / select (menu item) /
2300
+ // remove (chip × button). Outside-clicks close the menu via the catch-all
2301
+ // at the end of this handler.
2302
+ var addTagBtn=e.target.closest('[data-add-tag-filter]');
2303
+ if(addTagBtn){if(tagMenuOpen()){closeTagMenu()}else{openTagMenu()}return}
2304
+ var tagItem=e.target.closest('[data-tag-menu-item]');
2305
+ if(tagItem){addTag(tagItem.getAttribute('data-tag-menu-item'));closeTagMenu();return}
2306
+ var tagRemove=e.target.closest('[data-remove-tag]');
2307
+ if(tagRemove){removeTag(tagRemove.getAttribute('data-remove-tag'));return}
2168
2308
  // Stat pill filter
2169
2309
  var pill=e.target.closest('.stat-pill[data-filter]');
2170
- if(pill){filterByStatus(pill.getAttribute('data-filter'));return}
2310
+ if(pill){toggleStatus(pill.getAttribute('data-filter'));return}
2171
2311
  // Clear filter
2172
- if(e.target.closest('[data-clear-filter]')){clearFilter();return}
2173
- // Test bar block click scroll to and highlight the target test card
2312
+ if(e.target.closest('[data-clear-filter]')){clearAllFilters();return}
2313
+ // Any other click closes the tag menu (outside-click dismiss).
2314
+ if(tagMenuOpen()&&!e.target.closest('[data-tag-menu]')){closeTagMenu()}
2315
+ // Test bar block click — scroll to and highlight the target test card,
2316
+ // and reflect the selection in location.hash so the URL is shareable.
2317
+ // replaceState (not pushState) keeps the back button useful after the
2318
+ // user has clicked through several bar blocks.
2174
2319
  var barBlock=e.target.closest('.test-bar-block[data-target]');
2175
- 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}
2320
+ 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)}}if(tid&&tid.indexOf('test-')===0){history.replaceState(null,'','#?testId='+encodeURIComponent(tid.slice(5)))}return}
2176
2321
  // Filmstrip step expand (skip if clicking a link inside).
2177
2322
  // <div>-based steps: toggle the open class on any click in the wrapper.
2178
2323
  // <details>-based steps: <summary> clicks are handled natively; padding
@@ -2192,18 +2337,90 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2192
2337
  // Audit check row expand
2193
2338
  var auditCheck=e.target.closest('.audit-check.expandable');
2194
2339
  if(auditCheck){auditCheck.classList.toggle('open');return}
2195
- // Test card expand
2340
+ // Test card expand — also reflect the selection in location.hash on
2341
+ // open so the URL is shareable. Same replaceState rationale as the bar
2342
+ // block handler above. Collapses leave the URL alone.
2196
2343
  var row=e.target.closest('.test-card[data-detail]');
2197
- if(row&&!e.target.closest('.test-detail')){row.classList.toggle('expanded');return}
2344
+ if(row&&!e.target.closest('.test-detail')){var nowOpen=row.classList.toggle('expanded');if(nowOpen&&row.id&&row.id.indexOf('test-')===0){history.replaceState(null,'','#?testId='+encodeURIComponent(row.id.slice(5)))}return}
2198
2345
  });
2199
2346
 
2200
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
2347
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();closeTagMenu();clearAllFilters()}});
2201
2348
 
2202
2349
  // Auto-expand failed/timedout/interrupted/healed tests
2203
2350
  document.querySelectorAll('.test-card.failed,.test-card.timedout,.test-card.interrupted,.test-card.healed').forEach(function(r){r.classList.add('expanded')});
2351
+
2352
+ // Collect the cumulative set of tags present across all test cards and
2353
+ // reveal the +tag-filter controls only when at least one tag exists.
2354
+ (function(){
2355
+ var seen=Object.create(null);
2356
+ document.querySelectorAll('.test-card').forEach(function(card){
2357
+ var raw=card.getAttribute('data-tags');if(!raw)return;
2358
+ try{var tags=JSON.parse(raw);if(Array.isArray(tags)){tags.forEach(function(t){if(typeof t==='string'&&t)seen[t]=true})}}catch(_){}
2359
+ });
2360
+ allTags=Object.keys(seen).sort();
2361
+ var controls=document.querySelector('[data-tag-filter-controls]');
2362
+ if(controls&&allTags.length>0)controls.hidden=false;
2363
+ })();
2364
+
2365
+ // Seed filter state from ?status=...&tag=... so shared URLs restore the
2366
+ // view. Status values not in the known stat-pill set are ignored; tag
2367
+ // values not present in this report are dropped so a stale URL can't
2368
+ // poison the state.
2369
+ (function(){
2370
+ var p=new URLSearchParams(location.search);
2371
+ var s=p.get('status');
2372
+ var validStatuses={};
2373
+ document.querySelectorAll('.stat-pill[data-filter]').forEach(function(el){validStatuses[el.getAttribute('data-filter')]=true});
2374
+ if(s&&validStatuses[s]){
2375
+ activeStatus=s;
2376
+ document.querySelectorAll('.stat-pill').forEach(function(el){el.classList.toggle('active',el.getAttribute('data-filter')===s)});
2377
+ }
2378
+ var tagSet={};allTags.forEach(function(t){tagSet[t]=true});
2379
+ p.getAll('tag').forEach(function(t){if(tagSet[t])activeTags.add(t)});
2380
+ if(activeTags.size>0)renderActiveChips();
2381
+ if(activeStatus!==null||activeTags.size>0)applyFilters();
2382
+ })();
2383
+
2384
+ // Open #?testId=<id> deep links to the matching test card. Used by the
2385
+ // per-test redirect stubs (one per Playwright test-results directory) and
2386
+ // by any external link that wants to permalink a specific test.
2387
+ function focusTestFromHash(){
2388
+ var h=location.hash||'';
2389
+ if(h.indexOf('#?')!==0)return;
2390
+ var params=new URLSearchParams(h.slice(2));
2391
+ var id=params.get('testId');
2392
+ if(!id)return;
2393
+ var card=document.getElementById('test-'+id);
2394
+ if(!card||!card.classList.contains('test-card'))return;
2395
+ card.classList.add('expanded');
2396
+ card.scrollIntoView({behavior:'smooth',block:'center'});
2397
+ }
2398
+ focusTestFromHash();
2399
+ window.addEventListener('hashchange',focusTestFromHash);
2204
2400
  })();
2205
2401
  </script>
2206
2402
  </body>
2207
2403
  </html>`;
2208
2404
  }
2405
+ /**
2406
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
2407
+ * per-test directory under `test-results/`. The stub bounces straight to the
2408
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
2409
+ * visible fallback link, so it works with or without JS, online or `file://`.
2410
+ *
2411
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
2412
+ * directories — the caller in `html.ts` only writes this file into directories
2413
+ * Playwright already created for the test's attachments.
2414
+ */
2415
+ function renderPerTestStub(params) {
2416
+ const { testId, title, relPathToReport } = params;
2417
+ const href = `${relPathToReport}#?testId=${encodeURIComponent(testId)}`;
2418
+ return `<!DOCTYPE html>
2419
+ <meta charset="UTF-8">
2420
+ <title>Donobu — ${esc(title)}</title>
2421
+ <meta http-equiv="refresh" content="0; url=${esc(href)}">
2422
+ <script>location.replace(${JSON.stringify(href)});</script>
2423
+ <p>Redirecting to <a href="${esc(href)}">test report</a>…</p>
2424
+ `;
2425
+ }
2209
2426
  //# sourceMappingURL=render.js.map
@@ -48,6 +48,10 @@ function buildDonobuReport(resultsByTest, rootDir) {
48
48
  const testEntries = tests.map((test) => {
49
49
  const results = resultsByTest.get(test) ?? [];
50
50
  return {
51
+ // Playwright's stable per-(file, project, title) ID — used by the
52
+ // merge step's `byId` index and by the HTML renderer for permalink
53
+ // anchors (`index.html#?testId=<id>`) and the per-test redirect stubs.
54
+ testId: test.id,
51
55
  annotations: test.annotations,
52
56
  tags: test.tags,
53
57
  projectName: getProjectName(test),
@@ -51,5 +51,16 @@ export default class DonobuHtmlReporter implements Reporter {
51
51
  onTestEnd(test: TestCase, result: TestResult): void;
52
52
  onEnd(_result: FullResult): Promise<void>;
53
53
  printsToStdio(): boolean;
54
+ /**
55
+ * Drop a tiny redirect `index.html` into each Playwright-managed per-test
56
+ * directory under `outputDir`. The stub points back at the combined report's
57
+ * `#?testId=<id>` deep link, giving every test a stable URL inside its own
58
+ * directory without duplicating any of the rendered HTML.
59
+ *
60
+ * Directories are discovered from `dirname(attachment.path)` — Donobu does
61
+ * not invent any directory naming or layout. Tests with no attachments (and
62
+ * therefore no Playwright-created directory) get no stub.
63
+ */
64
+ private writePerTestStubs;
54
65
  }
55
66
  //# sourceMappingURL=html.d.ts.map
@@ -82,10 +82,65 @@ class DonobuHtmlReporter {
82
82
  (0, fs_1.mkdirSync)(outputDir, { recursive: true });
83
83
  (0, fs_1.writeFileSync)(outputFile, html, 'utf8');
84
84
  Logger_1.appLogger.info(`Donobu report written to ${outputFile}`);
85
+ this.writePerTestStubs(outputFile, outputDir);
85
86
  }
86
87
  printsToStdio() {
87
88
  return false;
88
89
  }
90
+ /**
91
+ * Drop a tiny redirect `index.html` into each Playwright-managed per-test
92
+ * directory under `outputDir`. The stub points back at the combined report's
93
+ * `#?testId=<id>` deep link, giving every test a stable URL inside its own
94
+ * directory without duplicating any of the rendered HTML.
95
+ *
96
+ * Directories are discovered from `dirname(attachment.path)` — Donobu does
97
+ * not invent any directory naming or layout. Tests with no attachments (and
98
+ * therefore no Playwright-created directory) get no stub.
99
+ */
100
+ writePerTestStubs(outputFile, outputDir) {
101
+ for (const [test, results] of this.resultsByTest) {
102
+ if (!test.id) {
103
+ continue;
104
+ }
105
+ const testDirs = new Set();
106
+ for (const result of results) {
107
+ for (const att of result.attachments) {
108
+ if (!att.path) {
109
+ continue;
110
+ }
111
+ const attDir = (0, path_1.dirname)((0, path_1.resolve)(att.path));
112
+ const rel = (0, path_1.relative)(outputDir, attDir);
113
+ if (!rel || rel.startsWith('..')) {
114
+ // Attachment lives outside the report's output directory — skip;
115
+ // we shouldn't be writing into arbitrary paths.
116
+ continue;
117
+ }
118
+ testDirs.add(attDir);
119
+ }
120
+ }
121
+ if (testDirs.size === 0) {
122
+ continue;
123
+ }
124
+ const fileBase = test.location.file.split('/').pop() ?? test.location.file;
125
+ const title = `${fileBase} › ${test.title}`;
126
+ for (const testDir of testDirs) {
127
+ const stubPath = (0, path_1.resolve)(testDir, 'index.html');
128
+ const relPathToReport = (0, path_1.relative)(testDir, outputFile);
129
+ const stub = (0, render_1.renderPerTestStub)({
130
+ testId: test.id,
131
+ title,
132
+ relPathToReport,
133
+ });
134
+ try {
135
+ (0, fs_1.mkdirSync)(testDir, { recursive: true });
136
+ (0, fs_1.writeFileSync)(stubPath, stub, 'utf8');
137
+ }
138
+ catch (err) {
139
+ Logger_1.appLogger.warn(`Failed to write per-test redirect stub at ${stubPath}: ${err.message}`);
140
+ }
141
+ }
142
+ }
143
+ }
89
144
  }
90
145
  exports.default = DonobuHtmlReporter;
91
146
  //# sourceMappingURL=html.js.map
@@ -140,5 +140,20 @@ export interface TriageData {
140
140
  }
141
141
  export declare function loadTriageData(triageDir: string): TriageData;
142
142
  export declare function renderHtml(report: DonobuReport, triage: TriageData, outputDir: string | null): string;
143
+ /**
144
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
145
+ * per-test directory under `test-results/`. The stub bounces straight to the
146
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
147
+ * visible fallback link, so it works with or without JS, online or `file://`.
148
+ *
149
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
150
+ * directories — the caller in `html.ts` only writes this file into directories
151
+ * Playwright already created for the test's attachments.
152
+ */
153
+ export declare function renderPerTestStub(params: {
154
+ testId: string;
155
+ title: string;
156
+ relPathToReport: string;
157
+ }): string;
143
158
  export {};
144
159
  //# sourceMappingURL=render.d.ts.map
@@ -10,6 +10,7 @@
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.loadTriageData = loadTriageData;
12
12
  exports.renderHtml = renderHtml;
13
+ exports.renderPerTestStub = renderPerTestStub;
13
14
  const fs_1 = require("fs");
14
15
  const path_1 = require("path");
15
16
  const ansi_1 = require("../utils/ansi");
@@ -366,6 +367,7 @@ function extractTests(jsonData) {
366
367
  tests.push({
367
368
  file: suite.file,
368
369
  specTitle: spec.title,
370
+ testId: typeof test.testId === 'string' ? test.testId : '',
369
371
  status,
370
372
  isSelfHealed,
371
373
  objective: objectiveAnnotation?.description ?? null,
@@ -722,7 +724,7 @@ function renderAiInvocation(inv, childrenHtml) {
722
724
  miss: 'cache · miss',
723
725
  };
724
726
  const cacheTitle = {
725
- hit: 'Replayed from the page-AI cache. No AI call this run.',
727
+ hit: 'Replayed from the page-AI cache; no AI used for this step.',
726
728
  stored: 'Live AI run; the resulting locators/steps were recorded to the page-AI cache. The next run can replay them without calling the AI.',
727
729
  miss: "Live AI run; nothing was recorded to the page-AI cache. The next run will hit the AI again. For asserts, this typically means the AI's structured Playwright locators didn't reproduce its screenshot verdict.",
728
730
  };
@@ -1530,8 +1532,10 @@ function renderHtml(report, triage, outputDir) {
1530
1532
  // Group by file
1531
1533
  const uniqueFiles = new Set(tests.map((t) => t.file));
1532
1534
  // --- Build HTML sections ---
1533
- // Pre-assign stable IDs for each test (used by both bar blocks and cards)
1534
- const testIds = tests.map(() => `t-${uid()}`);
1535
+ // Per-card IDs used by the test-detail div, bar-block `data-target`, and the
1536
+ // outer `.test-card` anchor that `#?testId=<id>` deep links target. Sourced
1537
+ // from Playwright's stable `TestCase.id`.
1538
+ const testIds = tests.map((t) => `test-${t.testId}`);
1535
1539
  // Build test bar blocks (one square per test, ordered by status, clickable)
1536
1540
  const statusOrder = [
1537
1541
  'passed',
@@ -1674,7 +1678,7 @@ function renderHtml(report, triage, outputDir) {
1674
1678
  ? `<div class="flow-id-detail"><span class="detail-label">Flow ID</span><span class="flow-id-value">${esc(test.flowId)}<button class="copy-flow-id" data-flow-id="${esc(test.flowId)}" title="Copy flow ID"><svg viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg></button></span></div>`
1675
1679
  : '';
1676
1680
  testSectionsHtml += `
1677
- <div class="test-card ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" data-status="${test.status}" ${hasDetails ? `data-detail="${testId}"` : ''}>
1681
+ <div class="test-card ${sc.label.toLowerCase().replace(/ /g, '')} ${expandableClass}" id="${testId}" data-status="${test.status}" data-tags="${esc(JSON.stringify(test.tags))}" ${hasDetails ? `data-detail="${testId}"` : ''}>
1678
1682
  <div class="test-summary">
1679
1683
  ${chevron}
1680
1684
  <span class="status-dot" style="background:${sc.color}" title="${sc.label}"></span>
@@ -1686,7 +1690,7 @@ function renderHtml(report, triage, outputDir) {
1686
1690
  ${totalStepCount > 0 ? `<span class="test-step-count" title="${totalStepCount} steps">${totalStepCount} steps</span>` : ''}
1687
1691
  <span class="test-duration">${fmtDuration(totalTestDuration)}</span>
1688
1692
  </div>
1689
- ${hasDetails ? `<div class="test-detail" id="${testId}">${flowIdDetailHtml}${detailsHtml}</div>` : ''}
1693
+ ${hasDetails ? `<div class="test-detail" id="detail-${testId}">${flowIdDetailHtml}${detailsHtml}</div>` : ''}
1690
1694
  </div>`;
1691
1695
  }
1692
1696
  const mergedBanner = isMergedReport
@@ -1768,6 +1772,32 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1768
1772
  .clear-filter:hover{background:var(--surface-raised);border-color:var(--text-dim);color:var(--text)}
1769
1773
  .clear-filter.visible{display:flex}
1770
1774
 
1775
+ /* Tag filter controls: + button opens a menu of available tags; selecting one
1776
+ * adds a removable chip. Multiple chips combine with logical AND. */
1777
+ .tag-filter-controls{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
1778
+ .tag-filter-trigger-wrap{position:relative;display:inline-flex}
1779
+ .add-tag-filter{background:var(--surface);border:1px solid var(--border);color:var(--text-muted);height:28px;padding:0 12px;border-radius:var(--radius);cursor:pointer;font-size:12px;font-weight:600;font-family:inherit;display:inline-flex;align-items:center;gap:4px;flex-shrink:0;transition:all .2s;line-height:1}
1780
+ .add-tag-filter .add-tag-plus{font-size:15px;line-height:1}
1781
+ .add-tag-filter:hover{background:var(--surface-raised);border-color:var(--text-dim);color:var(--text)}
1782
+ .add-tag-filter.active{background:var(--accent);border-color:var(--accent);color:#fff}
1783
+ .tag-menu{position:absolute;top:calc(100% + 6px);left:0;min-width:200px;max-width:320px;max-height:280px;overflow-y:auto;background:var(--surface-raised);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:20;padding:4px;display:none}
1784
+ .tag-menu:not([hidden]){display:block}
1785
+ .tag-menu-item{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:6px 10px;font-size:12px;font-family:var(--mono);color:var(--text);background:transparent;border:none;border-radius:4px;cursor:pointer;text-align:left;width:100%;transition:background .15s}
1786
+ .tag-menu-item:hover{background:var(--surface)}
1787
+ .tag-menu-item .tag-menu-count{color:var(--text-muted);font-size:11px;font-family:var(--mono)}
1788
+ .tag-menu-empty{padding:8px 10px;font-size:12px;color:var(--text-muted);font-style:italic}
1789
+ .active-tag-filters{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
1790
+ .tag-chip{display:inline-flex;align-items:center;gap:6px;background:rgba(255,127,58,.12);border:1px solid rgba(255,127,58,.3);color:var(--accent);font-size:11px;font-family:var(--mono);padding:3px 4px 3px 8px;border-radius:4px}
1791
+ .tag-chip-remove{background:transparent;border:none;color:inherit;cursor:pointer;font-size:14px;line-height:1;padding:0 4px;font-family:inherit;opacity:.7;transition:opacity .15s}
1792
+ .tag-chip-remove:hover{opacity:1}
1793
+
1794
+ /* Total of cards visible under the currently composed filters (status + tags).
1795
+ * The stat-pill counts always reflect totals; this disambiguates when filters
1796
+ * intersect. Hidden until any filter is active. */
1797
+ .match-count{display:none;align-items:center;font-size:12px;color:var(--text-muted);font-family:var(--mono);padding:0 8px;height:28px;flex-shrink:0}
1798
+ .match-count.visible{display:inline-flex}
1799
+ .match-count-value{color:var(--text);font-weight:600;margin-left:6px}
1800
+
1771
1801
  /* Test cards */
1772
1802
  .test-card{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
1773
1803
  .test-card.failed,.test-card.timedout,.test-card.interrupted{border-color:rgba(239,68,68,.2)}
@@ -2104,7 +2134,15 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2104
2134
  <div class="test-bar">${testBarHtml}</div>
2105
2135
  <div class="summary-stats">
2106
2136
  <div class="stat-pills">${statPillsHtml}</div>
2107
- <button class="clear-filter" data-clear-filter>&#x2716; Clear Filter</button>
2137
+ <div class="tag-filter-controls" data-tag-filter-controls hidden>
2138
+ <div class="tag-filter-trigger-wrap">
2139
+ <button class="add-tag-filter" data-add-tag-filter title="Filter by tag"><span class="add-tag-plus">+</span> Tag</button>
2140
+ <div class="tag-menu" data-tag-menu hidden></div>
2141
+ </div>
2142
+ <div class="active-tag-filters" data-active-tag-filters></div>
2143
+ </div>
2144
+ <span class="match-count" data-match-count>Matches:<span class="match-count-value" data-match-count-value>0</span></span>
2145
+ <button class="clear-filter" data-clear-filter>&#x2716; Clear Filters</button>
2108
2146
  </div>
2109
2147
  </div>
2110
2148
 
@@ -2122,20 +2160,113 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2122
2160
 
2123
2161
  <script>
2124
2162
  (function(){
2125
- var activeFilter=null;
2126
- function filterByStatus(s){
2127
- if(activeFilter===s){clearFilter();return}
2128
- activeFilter=s;
2129
- document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===s)});
2130
- document.querySelector('.clear-filter').classList.add('visible');
2131
- document.querySelectorAll('.test-card').forEach(function(r){r.classList.toggle('hidden-by-filter',r.getAttribute('data-status')!==s)});
2163
+ // Filters compose: a card is visible iff its status matches the active
2164
+ // status filter (if any) AND it carries every tag in activeTags. The two
2165
+ // dimensions are independent; "Clear Filter" wipes both.
2166
+ var activeStatus=null;
2167
+ var activeTags=new Set();
2168
+ var allTags=[];
2169
+
2170
+ function cardTags(card){var raw=card.getAttribute('data-tags');if(!raw)return [];try{var v=JSON.parse(raw);return Array.isArray(v)?v:[]}catch(_){return []}}
2171
+ function tagCount(t){var n=0;document.querySelectorAll('.test-card').forEach(function(c){if(cardTags(c).indexOf(t)!==-1)n++});return n}
2172
+
2173
+ function applyFilters(){
2174
+ var anyActive=activeStatus!==null||activeTags.size>0;
2175
+ document.querySelector('.clear-filter').classList.toggle('visible',anyActive);
2176
+ var visible=0;
2177
+ document.querySelectorAll('.test-card').forEach(function(card){
2178
+ var statusOk=activeStatus===null||card.getAttribute('data-status')===activeStatus;
2179
+ var tagsOk=true;
2180
+ if(activeTags.size>0){
2181
+ var t=cardTags(card);
2182
+ activeTags.forEach(function(want){if(t.indexOf(want)===-1)tagsOk=false});
2183
+ }
2184
+ var hide=!(statusOk&&tagsOk);
2185
+ card.classList.toggle('hidden-by-filter',hide);
2186
+ if(!hide)visible++;
2187
+ });
2188
+ var mc=document.querySelector('[data-match-count]');
2189
+ if(mc){
2190
+ mc.classList.toggle('visible',anyActive);
2191
+ var mv=mc.querySelector('[data-match-count-value]');
2192
+ if(mv)mv.textContent=visible;
2193
+ }
2194
+ syncFiltersToUrl();
2195
+ }
2196
+
2197
+ // Reflect the active filter state in the URL query string so the current
2198
+ // view is shareable. replaceState (not pushState) keeps the back button
2199
+ // useful; the existing #?testId=<id> hash is preserved.
2200
+ function syncFiltersToUrl(){
2201
+ var p=new URLSearchParams();
2202
+ if(activeStatus)p.set('status',activeStatus);
2203
+ activeTags.forEach(function(t){p.append('tag',t)});
2204
+ var qs=p.toString();
2205
+ var next=location.pathname+(qs?'?'+qs:'')+(location.hash||'');
2206
+ if(next!==location.pathname+location.search+location.hash){
2207
+ history.replaceState(null,'',next);
2208
+ }
2209
+ }
2210
+
2211
+ function toggleStatus(s){
2212
+ activeStatus=(activeStatus===s)?null:s;
2213
+ document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===activeStatus)});
2214
+ applyFilters();
2215
+ }
2216
+
2217
+ function renderActiveChips(){
2218
+ var c=document.querySelector('[data-active-tag-filters]');
2219
+ if(!c)return;
2220
+ c.innerHTML='';
2221
+ activeTags.forEach(function(t){
2222
+ var chip=document.createElement('span');chip.className='tag-chip';
2223
+ var label=document.createElement('span');label.textContent=t;
2224
+ var btn=document.createElement('button');btn.className='tag-chip-remove';btn.setAttribute('data-remove-tag',t);btn.setAttribute('title','Remove filter');btn.textContent='×';
2225
+ chip.appendChild(label);chip.appendChild(btn);
2226
+ c.appendChild(chip);
2227
+ });
2132
2228
  }
2133
- function clearFilter(){
2134
- activeFilter=null;
2229
+ function addTag(t){if(!t||activeTags.has(t))return;activeTags.add(t);renderActiveChips();applyFilters()}
2230
+ function removeTag(t){if(!activeTags.delete(t))return;renderActiveChips();applyFilters()}
2231
+
2232
+ function openTagMenu(){
2233
+ var menu=document.querySelector('[data-tag-menu]');
2234
+ if(!menu)return;
2235
+ var trigger=document.querySelector('[data-add-tag-filter]');
2236
+ menu.innerHTML='';
2237
+ var available=allTags.filter(function(t){return !activeTags.has(t)});
2238
+ if(available.length===0){
2239
+ var empty=document.createElement('div');empty.className='tag-menu-empty';
2240
+ empty.textContent=allTags.length?'All tags selected':'No tags';
2241
+ menu.appendChild(empty);
2242
+ }else{
2243
+ available.forEach(function(t){
2244
+ var item=document.createElement('button');item.className='tag-menu-item';item.setAttribute('data-tag-menu-item',t);
2245
+ var label=document.createElement('span');label.textContent=t;
2246
+ var count=document.createElement('span');count.className='tag-menu-count';count.textContent=tagCount(t);
2247
+ item.appendChild(label);item.appendChild(count);
2248
+ menu.appendChild(item);
2249
+ });
2250
+ }
2251
+ menu.hidden=false;
2252
+ if(trigger)trigger.classList.add('active');
2253
+ }
2254
+ function closeTagMenu(){
2255
+ var menu=document.querySelector('[data-tag-menu]');var trigger=document.querySelector('[data-add-tag-filter]');
2256
+ if(menu)menu.hidden=true;
2257
+ if(trigger)trigger.classList.remove('active');
2258
+ }
2259
+ function tagMenuOpen(){var m=document.querySelector('[data-tag-menu]');return !!(m&&!m.hidden)}
2260
+
2261
+ function clearAllFilters(){
2262
+ activeStatus=null;
2263
+ activeTags.clear();
2135
2264
  document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.remove('active')});
2136
- document.querySelector('.clear-filter').classList.remove('visible');
2137
- document.querySelectorAll('.test-card').forEach(function(r){r.classList.remove('hidden-by-filter')});
2265
+ renderActiveChips();
2266
+ closeTagMenu();
2267
+ applyFilters();
2138
2268
  }
2269
+
2139
2270
  function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
2140
2271
 
2141
2272
  document.addEventListener('click',function(e){
@@ -2165,14 +2296,28 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2165
2296
  if(flowBtn){e.stopPropagation();navigator.clipboard.writeText(flowBtn.getAttribute('data-flow-id'));var copySvg=flowBtn.innerHTML;flowBtn.innerHTML='<svg class="check-icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>';setTimeout(function(){flowBtn.innerHTML=copySvg},2000);return}
2166
2297
  var jsonBtn=e.target.closest('.copy-json');
2167
2298
  if(jsonBtn){e.stopPropagation();var pre=jsonBtn.closest('.step-json-wrap').querySelector('.step-json');navigator.clipboard.writeText(pre.textContent);var s=jsonBtn.innerHTML;jsonBtn.innerHTML='<svg class="check-icon" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></svg>';setTimeout(function(){jsonBtn.innerHTML=s},2000);return}
2299
+ // Tag filter: add (+ button toggles the menu) / select (menu item) /
2300
+ // remove (chip × button). Outside-clicks close the menu via the catch-all
2301
+ // at the end of this handler.
2302
+ var addTagBtn=e.target.closest('[data-add-tag-filter]');
2303
+ if(addTagBtn){if(tagMenuOpen()){closeTagMenu()}else{openTagMenu()}return}
2304
+ var tagItem=e.target.closest('[data-tag-menu-item]');
2305
+ if(tagItem){addTag(tagItem.getAttribute('data-tag-menu-item'));closeTagMenu();return}
2306
+ var tagRemove=e.target.closest('[data-remove-tag]');
2307
+ if(tagRemove){removeTag(tagRemove.getAttribute('data-remove-tag'));return}
2168
2308
  // Stat pill filter
2169
2309
  var pill=e.target.closest('.stat-pill[data-filter]');
2170
- if(pill){filterByStatus(pill.getAttribute('data-filter'));return}
2310
+ if(pill){toggleStatus(pill.getAttribute('data-filter'));return}
2171
2311
  // Clear filter
2172
- if(e.target.closest('[data-clear-filter]')){clearFilter();return}
2173
- // Test bar block click scroll to and highlight the target test card
2312
+ if(e.target.closest('[data-clear-filter]')){clearAllFilters();return}
2313
+ // Any other click closes the tag menu (outside-click dismiss).
2314
+ if(tagMenuOpen()&&!e.target.closest('[data-tag-menu]')){closeTagMenu()}
2315
+ // Test bar block click — scroll to and highlight the target test card,
2316
+ // and reflect the selection in location.hash so the URL is shareable.
2317
+ // replaceState (not pushState) keeps the back button useful after the
2318
+ // user has clicked through several bar blocks.
2174
2319
  var barBlock=e.target.closest('.test-bar-block[data-target]');
2175
- 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}
2320
+ 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)}}if(tid&&tid.indexOf('test-')===0){history.replaceState(null,'','#?testId='+encodeURIComponent(tid.slice(5)))}return}
2176
2321
  // Filmstrip step expand (skip if clicking a link inside).
2177
2322
  // <div>-based steps: toggle the open class on any click in the wrapper.
2178
2323
  // <details>-based steps: <summary> clicks are handled natively; padding
@@ -2192,18 +2337,90 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2192
2337
  // Audit check row expand
2193
2338
  var auditCheck=e.target.closest('.audit-check.expandable');
2194
2339
  if(auditCheck){auditCheck.classList.toggle('open');return}
2195
- // Test card expand
2340
+ // Test card expand — also reflect the selection in location.hash on
2341
+ // open so the URL is shareable. Same replaceState rationale as the bar
2342
+ // block handler above. Collapses leave the URL alone.
2196
2343
  var row=e.target.closest('.test-card[data-detail]');
2197
- if(row&&!e.target.closest('.test-detail')){row.classList.toggle('expanded');return}
2344
+ if(row&&!e.target.closest('.test-detail')){var nowOpen=row.classList.toggle('expanded');if(nowOpen&&row.id&&row.id.indexOf('test-')===0){history.replaceState(null,'','#?testId='+encodeURIComponent(row.id.slice(5)))}return}
2198
2345
  });
2199
2346
 
2200
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
2347
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();closeTagMenu();clearAllFilters()}});
2201
2348
 
2202
2349
  // Auto-expand failed/timedout/interrupted/healed tests
2203
2350
  document.querySelectorAll('.test-card.failed,.test-card.timedout,.test-card.interrupted,.test-card.healed').forEach(function(r){r.classList.add('expanded')});
2351
+
2352
+ // Collect the cumulative set of tags present across all test cards and
2353
+ // reveal the +tag-filter controls only when at least one tag exists.
2354
+ (function(){
2355
+ var seen=Object.create(null);
2356
+ document.querySelectorAll('.test-card').forEach(function(card){
2357
+ var raw=card.getAttribute('data-tags');if(!raw)return;
2358
+ try{var tags=JSON.parse(raw);if(Array.isArray(tags)){tags.forEach(function(t){if(typeof t==='string'&&t)seen[t]=true})}}catch(_){}
2359
+ });
2360
+ allTags=Object.keys(seen).sort();
2361
+ var controls=document.querySelector('[data-tag-filter-controls]');
2362
+ if(controls&&allTags.length>0)controls.hidden=false;
2363
+ })();
2364
+
2365
+ // Seed filter state from ?status=...&tag=... so shared URLs restore the
2366
+ // view. Status values not in the known stat-pill set are ignored; tag
2367
+ // values not present in this report are dropped so a stale URL can't
2368
+ // poison the state.
2369
+ (function(){
2370
+ var p=new URLSearchParams(location.search);
2371
+ var s=p.get('status');
2372
+ var validStatuses={};
2373
+ document.querySelectorAll('.stat-pill[data-filter]').forEach(function(el){validStatuses[el.getAttribute('data-filter')]=true});
2374
+ if(s&&validStatuses[s]){
2375
+ activeStatus=s;
2376
+ document.querySelectorAll('.stat-pill').forEach(function(el){el.classList.toggle('active',el.getAttribute('data-filter')===s)});
2377
+ }
2378
+ var tagSet={};allTags.forEach(function(t){tagSet[t]=true});
2379
+ p.getAll('tag').forEach(function(t){if(tagSet[t])activeTags.add(t)});
2380
+ if(activeTags.size>0)renderActiveChips();
2381
+ if(activeStatus!==null||activeTags.size>0)applyFilters();
2382
+ })();
2383
+
2384
+ // Open #?testId=<id> deep links to the matching test card. Used by the
2385
+ // per-test redirect stubs (one per Playwright test-results directory) and
2386
+ // by any external link that wants to permalink a specific test.
2387
+ function focusTestFromHash(){
2388
+ var h=location.hash||'';
2389
+ if(h.indexOf('#?')!==0)return;
2390
+ var params=new URLSearchParams(h.slice(2));
2391
+ var id=params.get('testId');
2392
+ if(!id)return;
2393
+ var card=document.getElementById('test-'+id);
2394
+ if(!card||!card.classList.contains('test-card'))return;
2395
+ card.classList.add('expanded');
2396
+ card.scrollIntoView({behavior:'smooth',block:'center'});
2397
+ }
2398
+ focusTestFromHash();
2399
+ window.addEventListener('hashchange',focusTestFromHash);
2204
2400
  })();
2205
2401
  </script>
2206
2402
  </body>
2207
2403
  </html>`;
2208
2404
  }
2405
+ /**
2406
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
2407
+ * per-test directory under `test-results/`. The stub bounces straight to the
2408
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
2409
+ * visible fallback link, so it works with or without JS, online or `file://`.
2410
+ *
2411
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
2412
+ * directories — the caller in `html.ts` only writes this file into directories
2413
+ * Playwright already created for the test's attachments.
2414
+ */
2415
+ function renderPerTestStub(params) {
2416
+ const { testId, title, relPathToReport } = params;
2417
+ const href = `${relPathToReport}#?testId=${encodeURIComponent(testId)}`;
2418
+ return `<!DOCTYPE html>
2419
+ <meta charset="UTF-8">
2420
+ <title>Donobu — ${esc(title)}</title>
2421
+ <meta http-equiv="refresh" content="0; url=${esc(href)}">
2422
+ <script>location.replace(${JSON.stringify(href)});</script>
2423
+ <p>Redirecting to <a href="${esc(href)}">test report</a>…</p>
2424
+ `;
2425
+ }
2209
2426
  //# sourceMappingURL=render.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.41.4",
3
+ "version": "5.42.0",
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",