donobu 5.41.4 → 5.43.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))}"${test.plan ? ` data-reason="${esc(test.plan.plan.failureReason)}"` : ''} ${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,34 @@ 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
+ .tag-menu-section{padding:8px 10px 4px;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-dim);font-family:inherit}
1790
+ .tag-menu-section:not(:first-child){margin-top:4px;border-top:1px solid var(--border)}
1791
+ .active-tag-filters{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
1792
+ .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}
1793
+ .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}
1794
+ .tag-chip-remove:hover{opacity:1}
1795
+
1796
+ /* Total of cards visible under the currently composed filters (status + tags).
1797
+ * The stat-pill counts always reflect totals; this disambiguates when filters
1798
+ * intersect. Hidden until any filter is active. */
1799
+ .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}
1800
+ .match-count.visible{display:inline-flex}
1801
+ .match-count-value{color:var(--text);font-weight:600;margin-left:6px}
1802
+
1771
1803
  /* Test cards */
1772
1804
  .test-card{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
1773
1805
  .test-card.failed,.test-card.timedout,.test-card.interrupted{border-color:rgba(239,68,68,.2)}
@@ -2104,7 +2136,15 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2104
2136
  <div class="test-bar">${testBarHtml}</div>
2105
2137
  <div class="summary-stats">
2106
2138
  <div class="stat-pills">${statPillsHtml}</div>
2107
- <button class="clear-filter" data-clear-filter>&#x2716; Clear Filter</button>
2139
+ <div class="tag-filter-controls" data-tag-filter-controls hidden>
2140
+ <div class="tag-filter-trigger-wrap">
2141
+ <button class="add-tag-filter" data-add-tag-filter title="Filter by tag or diagnosis"><span class="add-tag-plus">+</span> Filter</button>
2142
+ <div class="tag-menu" data-tag-menu hidden></div>
2143
+ </div>
2144
+ <div class="active-tag-filters" data-active-tag-filters></div>
2145
+ </div>
2146
+ <span class="match-count" data-match-count>Matches:<span class="match-count-value" data-match-count-value>0</span></span>
2147
+ <button class="clear-filter" data-clear-filter>&#x2716; Clear Filters</button>
2108
2148
  </div>
2109
2149
  </div>
2110
2150
 
@@ -2122,20 +2162,173 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2122
2162
 
2123
2163
  <script>
2124
2164
  (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)});
2165
+ // Filters compose across three dimensions:
2166
+ // status — single-select; card.data-status must match (if set).
2167
+ // tags — multi-select AND; card must carry every active tag.
2168
+ // reasons — multi-select OR; card.data-reason must match any active reason
2169
+ // (a card has at most one diagnosis, so AND would always be 0/1).
2170
+ // "Clear Filters" wipes all three.
2171
+ var activeStatus=null;
2172
+ var activeTags=new Set();
2173
+ var activeReasons=new Set();
2174
+ var allTags=[];
2175
+ var allReasons=[]; // ordered list of REASON keys present in the report
2176
+ var REASON_LABELS=${JSON.stringify(REASON_LABELS)};
2177
+
2178
+ 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 []}}
2179
+ function tagCount(t){var n=0;document.querySelectorAll('.test-card').forEach(function(c){if(cardTags(c).indexOf(t)!==-1)n++});return n}
2180
+
2181
+ function applyFilters(){
2182
+ var anyActive=activeStatus!==null||activeTags.size>0||activeReasons.size>0;
2183
+ document.querySelector('.clear-filter').classList.toggle('visible',anyActive);
2184
+ var visible=0;
2185
+ document.querySelectorAll('.test-card').forEach(function(card){
2186
+ var statusOk=activeStatus===null||card.getAttribute('data-status')===activeStatus;
2187
+ var tagsOk=true;
2188
+ if(activeTags.size>0){
2189
+ var t=cardTags(card);
2190
+ activeTags.forEach(function(want){if(t.indexOf(want)===-1)tagsOk=false});
2191
+ }
2192
+ var reasonOk=true;
2193
+ if(activeReasons.size>0){
2194
+ var r=card.getAttribute('data-reason')||'';
2195
+ reasonOk=activeReasons.has(r);
2196
+ }
2197
+ var hide=!(statusOk&&tagsOk&&reasonOk);
2198
+ card.classList.toggle('hidden-by-filter',hide);
2199
+ if(!hide)visible++;
2200
+ });
2201
+ var mc=document.querySelector('[data-match-count]');
2202
+ if(mc){
2203
+ mc.classList.toggle('visible',anyActive);
2204
+ var mv=mc.querySelector('[data-match-count-value]');
2205
+ if(mv)mv.textContent=visible;
2206
+ }
2207
+ syncFiltersToUrl();
2132
2208
  }
2133
- function clearFilter(){
2134
- activeFilter=null;
2209
+
2210
+ // Reflect the active filter state in the URL query string so the current
2211
+ // view is shareable. replaceState (not pushState) keeps the back button
2212
+ // useful; the existing #?testId=<id> hash is preserved.
2213
+ function syncFiltersToUrl(){
2214
+ var p=new URLSearchParams();
2215
+ if(activeStatus)p.set('status',activeStatus);
2216
+ activeTags.forEach(function(t){p.append('tag',t)});
2217
+ activeReasons.forEach(function(r){p.append('reason',r)});
2218
+ var qs=p.toString();
2219
+ var next=location.pathname+(qs?'?'+qs:'')+(location.hash||'');
2220
+ if(next!==location.pathname+location.search+location.hash){
2221
+ history.replaceState(null,'',next);
2222
+ }
2223
+ }
2224
+
2225
+ function toggleStatus(s){
2226
+ activeStatus=(activeStatus===s)?null:s;
2227
+ document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===activeStatus)});
2228
+ applyFilters();
2229
+ }
2230
+
2231
+ // Convert "#rrggbb" or "rgb(...)" into a low-alpha background for tinted
2232
+ // diagnosis chips. Falls back to the accent tint if parsing fails.
2233
+ function hexToRgba(hex,a){
2234
+ if(!hex||hex.charAt(0)!=='#'||(hex.length!==4&&hex.length!==7))return 'rgba(255,127,58,'+a+')';
2235
+ var s=hex.length===4?('#'+hex[1]+hex[1]+hex[2]+hex[2]+hex[3]+hex[3]):hex;
2236
+ var r=parseInt(s.slice(1,3),16),g=parseInt(s.slice(3,5),16),b=parseInt(s.slice(5,7),16);
2237
+ return 'rgba('+r+','+g+','+b+','+a+')';
2238
+ }
2239
+
2240
+ function renderActiveChips(){
2241
+ var c=document.querySelector('[data-active-tag-filters]');
2242
+ if(!c)return;
2243
+ c.innerHTML='';
2244
+ activeTags.forEach(function(t){
2245
+ var chip=document.createElement('span');chip.className='tag-chip';
2246
+ var label=document.createElement('span');label.textContent=t;
2247
+ var btn=document.createElement('button');btn.className='tag-chip-remove';btn.setAttribute('data-remove-tag',t);btn.setAttribute('title','Remove filter');btn.textContent='×';
2248
+ chip.appendChild(label);chip.appendChild(btn);
2249
+ c.appendChild(chip);
2250
+ });
2251
+ activeReasons.forEach(function(r){
2252
+ var meta=REASON_LABELS[r]||REASON_LABELS['UNKNOWN'];
2253
+ var chip=document.createElement('span');chip.className='tag-chip reason-chip';
2254
+ chip.style.background=hexToRgba(meta.color,0.14);
2255
+ chip.style.borderColor=hexToRgba(meta.color,0.4);
2256
+ chip.style.color=meta.color;
2257
+ var label=document.createElement('span');label.textContent=meta.label;
2258
+ var btn=document.createElement('button');btn.className='tag-chip-remove';btn.setAttribute('data-remove-reason',r);btn.setAttribute('title','Remove filter');btn.textContent='×';
2259
+ chip.appendChild(label);chip.appendChild(btn);
2260
+ c.appendChild(chip);
2261
+ });
2262
+ }
2263
+ function addTag(t){if(!t||activeTags.has(t))return;activeTags.add(t);renderActiveChips();applyFilters()}
2264
+ function removeTag(t){if(!activeTags.delete(t))return;renderActiveChips();applyFilters()}
2265
+ function addReason(r){if(!r||activeReasons.has(r))return;activeReasons.add(r);renderActiveChips();applyFilters()}
2266
+ function removeReason(r){if(!activeReasons.delete(r))return;renderActiveChips();applyFilters()}
2267
+
2268
+ function reasonCount(r){var n=0;document.querySelectorAll('.test-card').forEach(function(c){if(c.getAttribute('data-reason')===r)n++});return n}
2269
+
2270
+ function openTagMenu(){
2271
+ var menu=document.querySelector('[data-tag-menu]');
2272
+ if(!menu)return;
2273
+ var trigger=document.querySelector('[data-add-tag-filter]');
2274
+ menu.innerHTML='';
2275
+ var availTags=allTags.filter(function(t){return !activeTags.has(t)});
2276
+ var availReasons=allReasons.filter(function(r){return !activeReasons.has(r)});
2277
+ var added=false;
2278
+ if(allTags.length>0){
2279
+ var hT=document.createElement('div');hT.className='tag-menu-section';hT.textContent='Tags';menu.appendChild(hT);
2280
+ if(availTags.length===0){
2281
+ var emptyT=document.createElement('div');emptyT.className='tag-menu-empty';emptyT.textContent='All tags selected';menu.appendChild(emptyT);
2282
+ }else{
2283
+ availTags.forEach(function(t){
2284
+ var item=document.createElement('button');item.className='tag-menu-item';item.setAttribute('data-tag-menu-item',t);
2285
+ var label=document.createElement('span');label.textContent=t;
2286
+ var count=document.createElement('span');count.className='tag-menu-count';count.textContent=tagCount(t);
2287
+ item.appendChild(label);item.appendChild(count);
2288
+ menu.appendChild(item);
2289
+ });
2290
+ }
2291
+ added=true;
2292
+ }
2293
+ if(allReasons.length>0){
2294
+ var hR=document.createElement('div');hR.className='tag-menu-section';hR.textContent='Diagnoses';menu.appendChild(hR);
2295
+ if(availReasons.length===0){
2296
+ var emptyR=document.createElement('div');emptyR.className='tag-menu-empty';emptyR.textContent='All diagnoses selected';menu.appendChild(emptyR);
2297
+ }else{
2298
+ availReasons.forEach(function(r){
2299
+ var meta=REASON_LABELS[r]||REASON_LABELS['UNKNOWN'];
2300
+ var item=document.createElement('button');item.className='tag-menu-item';item.setAttribute('data-reason-menu-item',r);
2301
+ var label=document.createElement('span');label.textContent=meta.label;label.style.color=meta.color;
2302
+ var count=document.createElement('span');count.className='tag-menu-count';count.textContent=reasonCount(r);
2303
+ item.appendChild(label);item.appendChild(count);
2304
+ menu.appendChild(item);
2305
+ });
2306
+ }
2307
+ added=true;
2308
+ }
2309
+ if(!added){
2310
+ var empty=document.createElement('div');empty.className='tag-menu-empty';empty.textContent='No filters available';menu.appendChild(empty);
2311
+ }
2312
+ menu.hidden=false;
2313
+ if(trigger)trigger.classList.add('active');
2314
+ }
2315
+ function closeTagMenu(){
2316
+ var menu=document.querySelector('[data-tag-menu]');var trigger=document.querySelector('[data-add-tag-filter]');
2317
+ if(menu)menu.hidden=true;
2318
+ if(trigger)trigger.classList.remove('active');
2319
+ }
2320
+ function tagMenuOpen(){var m=document.querySelector('[data-tag-menu]');return !!(m&&!m.hidden)}
2321
+
2322
+ function clearAllFilters(){
2323
+ activeStatus=null;
2324
+ activeTags.clear();
2325
+ activeReasons.clear();
2135
2326
  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')});
2327
+ renderActiveChips();
2328
+ closeTagMenu();
2329
+ applyFilters();
2138
2330
  }
2331
+
2139
2332
  function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
2140
2333
 
2141
2334
  document.addEventListener('click',function(e){
@@ -2165,14 +2358,32 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2165
2358
  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
2359
  var jsonBtn=e.target.closest('.copy-json');
2167
2360
  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}
2361
+ // Tag filter: add (+ button toggles the menu) / select (menu item) /
2362
+ // remove (chip × button). Outside-clicks close the menu via the catch-all
2363
+ // at the end of this handler.
2364
+ var addTagBtn=e.target.closest('[data-add-tag-filter]');
2365
+ if(addTagBtn){if(tagMenuOpen()){closeTagMenu()}else{openTagMenu()}return}
2366
+ var tagItem=e.target.closest('[data-tag-menu-item]');
2367
+ if(tagItem){addTag(tagItem.getAttribute('data-tag-menu-item'));closeTagMenu();return}
2368
+ var reasonItem=e.target.closest('[data-reason-menu-item]');
2369
+ if(reasonItem){addReason(reasonItem.getAttribute('data-reason-menu-item'));closeTagMenu();return}
2370
+ var tagRemove=e.target.closest('[data-remove-tag]');
2371
+ if(tagRemove){removeTag(tagRemove.getAttribute('data-remove-tag'));return}
2372
+ var reasonRemove=e.target.closest('[data-remove-reason]');
2373
+ if(reasonRemove){removeReason(reasonRemove.getAttribute('data-remove-reason'));return}
2168
2374
  // Stat pill filter
2169
2375
  var pill=e.target.closest('.stat-pill[data-filter]');
2170
- if(pill){filterByStatus(pill.getAttribute('data-filter'));return}
2376
+ if(pill){toggleStatus(pill.getAttribute('data-filter'));return}
2171
2377
  // 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
2378
+ if(e.target.closest('[data-clear-filter]')){clearAllFilters();return}
2379
+ // Any other click closes the tag menu (outside-click dismiss).
2380
+ if(tagMenuOpen()&&!e.target.closest('[data-tag-menu]')){closeTagMenu()}
2381
+ // Test bar block click — scroll to and highlight the target test card,
2382
+ // and reflect the selection in location.hash so the URL is shareable.
2383
+ // replaceState (not pushState) keeps the back button useful after the
2384
+ // user has clicked through several bar blocks.
2174
2385
  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}
2386
+ 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
2387
  // Filmstrip step expand (skip if clicking a link inside).
2177
2388
  // <div>-based steps: toggle the open class on any click in the wrapper.
2178
2389
  // <details>-based steps: <summary> clicks are handled natively; padding
@@ -2192,18 +2403,98 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2192
2403
  // Audit check row expand
2193
2404
  var auditCheck=e.target.closest('.audit-check.expandable');
2194
2405
  if(auditCheck){auditCheck.classList.toggle('open');return}
2195
- // Test card expand
2406
+ // Test card expand — also reflect the selection in location.hash on
2407
+ // open so the URL is shareable. Same replaceState rationale as the bar
2408
+ // block handler above. Collapses leave the URL alone.
2196
2409
  var row=e.target.closest('.test-card[data-detail]');
2197
- if(row&&!e.target.closest('.test-detail')){row.classList.toggle('expanded');return}
2410
+ 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
2411
  });
2199
2412
 
2200
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
2413
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();closeTagMenu();clearAllFilters()}});
2201
2414
 
2202
2415
  // Auto-expand failed/timedout/interrupted/healed tests
2203
2416
  document.querySelectorAll('.test-card.failed,.test-card.timedout,.test-card.interrupted,.test-card.healed').forEach(function(r){r.classList.add('expanded')});
2417
+
2418
+ // Collect the cumulative set of tags + diagnosis reasons present across all
2419
+ // test cards. Reveal the +filter controls when at least one of either exists.
2420
+ (function(){
2421
+ var seenTags=Object.create(null);
2422
+ var seenReasons=Object.create(null);
2423
+ document.querySelectorAll('.test-card').forEach(function(card){
2424
+ var raw=card.getAttribute('data-tags');
2425
+ if(raw){try{var tags=JSON.parse(raw);if(Array.isArray(tags)){tags.forEach(function(t){if(typeof t==='string'&&t)seenTags[t]=true})}}catch(_){}}
2426
+ var r=card.getAttribute('data-reason');
2427
+ if(r)seenReasons[r]=true;
2428
+ });
2429
+ allTags=Object.keys(seenTags).sort();
2430
+ // Preserve the REASON_LABELS declaration order rather than alphabetical —
2431
+ // they're already arranged from most-frequent/specific to UNKNOWN catch-all.
2432
+ allReasons=Object.keys(REASON_LABELS).filter(function(r){return seenReasons[r]});
2433
+ var controls=document.querySelector('[data-tag-filter-controls]');
2434
+ if(controls&&(allTags.length>0||allReasons.length>0))controls.hidden=false;
2435
+ })();
2436
+
2437
+ // Seed filter state from ?status=...&tag=...&reason=... so shared URLs
2438
+ // restore the view. Status values not in the known stat-pill set are
2439
+ // ignored; tag and reason values not present in this report are dropped
2440
+ // so a stale URL can't poison the state.
2441
+ (function(){
2442
+ var p=new URLSearchParams(location.search);
2443
+ var s=p.get('status');
2444
+ var validStatuses={};
2445
+ document.querySelectorAll('.stat-pill[data-filter]').forEach(function(el){validStatuses[el.getAttribute('data-filter')]=true});
2446
+ if(s&&validStatuses[s]){
2447
+ activeStatus=s;
2448
+ document.querySelectorAll('.stat-pill').forEach(function(el){el.classList.toggle('active',el.getAttribute('data-filter')===s)});
2449
+ }
2450
+ var tagSet={};allTags.forEach(function(t){tagSet[t]=true});
2451
+ p.getAll('tag').forEach(function(t){if(tagSet[t])activeTags.add(t)});
2452
+ var reasonSet={};allReasons.forEach(function(r){reasonSet[r]=true});
2453
+ p.getAll('reason').forEach(function(r){if(reasonSet[r])activeReasons.add(r)});
2454
+ if(activeTags.size>0||activeReasons.size>0)renderActiveChips();
2455
+ if(activeStatus!==null||activeTags.size>0||activeReasons.size>0)applyFilters();
2456
+ })();
2457
+
2458
+ // Open #?testId=<id> deep links to the matching test card. Used by the
2459
+ // per-test redirect stubs (one per Playwright test-results directory) and
2460
+ // by any external link that wants to permalink a specific test.
2461
+ function focusTestFromHash(){
2462
+ var h=location.hash||'';
2463
+ if(h.indexOf('#?')!==0)return;
2464
+ var params=new URLSearchParams(h.slice(2));
2465
+ var id=params.get('testId');
2466
+ if(!id)return;
2467
+ var card=document.getElementById('test-'+id);
2468
+ if(!card||!card.classList.contains('test-card'))return;
2469
+ card.classList.add('expanded');
2470
+ card.scrollIntoView({behavior:'smooth',block:'center'});
2471
+ }
2472
+ focusTestFromHash();
2473
+ window.addEventListener('hashchange',focusTestFromHash);
2204
2474
  })();
2205
2475
  </script>
2206
2476
  </body>
2207
2477
  </html>`;
2208
2478
  }
2479
+ /**
2480
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
2481
+ * per-test directory under `test-results/`. The stub bounces straight to the
2482
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
2483
+ * visible fallback link, so it works with or without JS, online or `file://`.
2484
+ *
2485
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
2486
+ * directories — the caller in `html.ts` only writes this file into directories
2487
+ * Playwright already created for the test's attachments.
2488
+ */
2489
+ function renderPerTestStub(params) {
2490
+ const { testId, title, relPathToReport } = params;
2491
+ const href = `${relPathToReport}#?testId=${encodeURIComponent(testId)}`;
2492
+ return `<!DOCTYPE html>
2493
+ <meta charset="UTF-8">
2494
+ <title>Donobu — ${esc(title)}</title>
2495
+ <meta http-equiv="refresh" content="0; url=${esc(href)}">
2496
+ <script>location.replace(${JSON.stringify(href)});</script>
2497
+ <p>Redirecting to <a href="${esc(href)}">test report</a>…</p>
2498
+ `;
2499
+ }
2209
2500
  //# 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))}"${test.plan ? ` data-reason="${esc(test.plan.plan.failureReason)}"` : ''} ${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,34 @@ 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
+ .tag-menu-section{padding:8px 10px 4px;font-size:10px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--text-dim);font-family:inherit}
1790
+ .tag-menu-section:not(:first-child){margin-top:4px;border-top:1px solid var(--border)}
1791
+ .active-tag-filters{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}
1792
+ .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}
1793
+ .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}
1794
+ .tag-chip-remove:hover{opacity:1}
1795
+
1796
+ /* Total of cards visible under the currently composed filters (status + tags).
1797
+ * The stat-pill counts always reflect totals; this disambiguates when filters
1798
+ * intersect. Hidden until any filter is active. */
1799
+ .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}
1800
+ .match-count.visible{display:inline-flex}
1801
+ .match-count-value{color:var(--text);font-weight:600;margin-left:6px}
1802
+
1771
1803
  /* Test cards */
1772
1804
  .test-card{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
1773
1805
  .test-card.failed,.test-card.timedout,.test-card.interrupted{border-color:rgba(239,68,68,.2)}
@@ -2104,7 +2136,15 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2104
2136
  <div class="test-bar">${testBarHtml}</div>
2105
2137
  <div class="summary-stats">
2106
2138
  <div class="stat-pills">${statPillsHtml}</div>
2107
- <button class="clear-filter" data-clear-filter>&#x2716; Clear Filter</button>
2139
+ <div class="tag-filter-controls" data-tag-filter-controls hidden>
2140
+ <div class="tag-filter-trigger-wrap">
2141
+ <button class="add-tag-filter" data-add-tag-filter title="Filter by tag or diagnosis"><span class="add-tag-plus">+</span> Filter</button>
2142
+ <div class="tag-menu" data-tag-menu hidden></div>
2143
+ </div>
2144
+ <div class="active-tag-filters" data-active-tag-filters></div>
2145
+ </div>
2146
+ <span class="match-count" data-match-count>Matches:<span class="match-count-value" data-match-count-value>0</span></span>
2147
+ <button class="clear-filter" data-clear-filter>&#x2716; Clear Filters</button>
2108
2148
  </div>
2109
2149
  </div>
2110
2150
 
@@ -2122,20 +2162,173 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2122
2162
 
2123
2163
  <script>
2124
2164
  (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)});
2165
+ // Filters compose across three dimensions:
2166
+ // status — single-select; card.data-status must match (if set).
2167
+ // tags — multi-select AND; card must carry every active tag.
2168
+ // reasons — multi-select OR; card.data-reason must match any active reason
2169
+ // (a card has at most one diagnosis, so AND would always be 0/1).
2170
+ // "Clear Filters" wipes all three.
2171
+ var activeStatus=null;
2172
+ var activeTags=new Set();
2173
+ var activeReasons=new Set();
2174
+ var allTags=[];
2175
+ var allReasons=[]; // ordered list of REASON keys present in the report
2176
+ var REASON_LABELS=${JSON.stringify(REASON_LABELS)};
2177
+
2178
+ 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 []}}
2179
+ function tagCount(t){var n=0;document.querySelectorAll('.test-card').forEach(function(c){if(cardTags(c).indexOf(t)!==-1)n++});return n}
2180
+
2181
+ function applyFilters(){
2182
+ var anyActive=activeStatus!==null||activeTags.size>0||activeReasons.size>0;
2183
+ document.querySelector('.clear-filter').classList.toggle('visible',anyActive);
2184
+ var visible=0;
2185
+ document.querySelectorAll('.test-card').forEach(function(card){
2186
+ var statusOk=activeStatus===null||card.getAttribute('data-status')===activeStatus;
2187
+ var tagsOk=true;
2188
+ if(activeTags.size>0){
2189
+ var t=cardTags(card);
2190
+ activeTags.forEach(function(want){if(t.indexOf(want)===-1)tagsOk=false});
2191
+ }
2192
+ var reasonOk=true;
2193
+ if(activeReasons.size>0){
2194
+ var r=card.getAttribute('data-reason')||'';
2195
+ reasonOk=activeReasons.has(r);
2196
+ }
2197
+ var hide=!(statusOk&&tagsOk&&reasonOk);
2198
+ card.classList.toggle('hidden-by-filter',hide);
2199
+ if(!hide)visible++;
2200
+ });
2201
+ var mc=document.querySelector('[data-match-count]');
2202
+ if(mc){
2203
+ mc.classList.toggle('visible',anyActive);
2204
+ var mv=mc.querySelector('[data-match-count-value]');
2205
+ if(mv)mv.textContent=visible;
2206
+ }
2207
+ syncFiltersToUrl();
2132
2208
  }
2133
- function clearFilter(){
2134
- activeFilter=null;
2209
+
2210
+ // Reflect the active filter state in the URL query string so the current
2211
+ // view is shareable. replaceState (not pushState) keeps the back button
2212
+ // useful; the existing #?testId=<id> hash is preserved.
2213
+ function syncFiltersToUrl(){
2214
+ var p=new URLSearchParams();
2215
+ if(activeStatus)p.set('status',activeStatus);
2216
+ activeTags.forEach(function(t){p.append('tag',t)});
2217
+ activeReasons.forEach(function(r){p.append('reason',r)});
2218
+ var qs=p.toString();
2219
+ var next=location.pathname+(qs?'?'+qs:'')+(location.hash||'');
2220
+ if(next!==location.pathname+location.search+location.hash){
2221
+ history.replaceState(null,'',next);
2222
+ }
2223
+ }
2224
+
2225
+ function toggleStatus(s){
2226
+ activeStatus=(activeStatus===s)?null:s;
2227
+ document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===activeStatus)});
2228
+ applyFilters();
2229
+ }
2230
+
2231
+ // Convert "#rrggbb" or "rgb(...)" into a low-alpha background for tinted
2232
+ // diagnosis chips. Falls back to the accent tint if parsing fails.
2233
+ function hexToRgba(hex,a){
2234
+ if(!hex||hex.charAt(0)!=='#'||(hex.length!==4&&hex.length!==7))return 'rgba(255,127,58,'+a+')';
2235
+ var s=hex.length===4?('#'+hex[1]+hex[1]+hex[2]+hex[2]+hex[3]+hex[3]):hex;
2236
+ var r=parseInt(s.slice(1,3),16),g=parseInt(s.slice(3,5),16),b=parseInt(s.slice(5,7),16);
2237
+ return 'rgba('+r+','+g+','+b+','+a+')';
2238
+ }
2239
+
2240
+ function renderActiveChips(){
2241
+ var c=document.querySelector('[data-active-tag-filters]');
2242
+ if(!c)return;
2243
+ c.innerHTML='';
2244
+ activeTags.forEach(function(t){
2245
+ var chip=document.createElement('span');chip.className='tag-chip';
2246
+ var label=document.createElement('span');label.textContent=t;
2247
+ var btn=document.createElement('button');btn.className='tag-chip-remove';btn.setAttribute('data-remove-tag',t);btn.setAttribute('title','Remove filter');btn.textContent='×';
2248
+ chip.appendChild(label);chip.appendChild(btn);
2249
+ c.appendChild(chip);
2250
+ });
2251
+ activeReasons.forEach(function(r){
2252
+ var meta=REASON_LABELS[r]||REASON_LABELS['UNKNOWN'];
2253
+ var chip=document.createElement('span');chip.className='tag-chip reason-chip';
2254
+ chip.style.background=hexToRgba(meta.color,0.14);
2255
+ chip.style.borderColor=hexToRgba(meta.color,0.4);
2256
+ chip.style.color=meta.color;
2257
+ var label=document.createElement('span');label.textContent=meta.label;
2258
+ var btn=document.createElement('button');btn.className='tag-chip-remove';btn.setAttribute('data-remove-reason',r);btn.setAttribute('title','Remove filter');btn.textContent='×';
2259
+ chip.appendChild(label);chip.appendChild(btn);
2260
+ c.appendChild(chip);
2261
+ });
2262
+ }
2263
+ function addTag(t){if(!t||activeTags.has(t))return;activeTags.add(t);renderActiveChips();applyFilters()}
2264
+ function removeTag(t){if(!activeTags.delete(t))return;renderActiveChips();applyFilters()}
2265
+ function addReason(r){if(!r||activeReasons.has(r))return;activeReasons.add(r);renderActiveChips();applyFilters()}
2266
+ function removeReason(r){if(!activeReasons.delete(r))return;renderActiveChips();applyFilters()}
2267
+
2268
+ function reasonCount(r){var n=0;document.querySelectorAll('.test-card').forEach(function(c){if(c.getAttribute('data-reason')===r)n++});return n}
2269
+
2270
+ function openTagMenu(){
2271
+ var menu=document.querySelector('[data-tag-menu]');
2272
+ if(!menu)return;
2273
+ var trigger=document.querySelector('[data-add-tag-filter]');
2274
+ menu.innerHTML='';
2275
+ var availTags=allTags.filter(function(t){return !activeTags.has(t)});
2276
+ var availReasons=allReasons.filter(function(r){return !activeReasons.has(r)});
2277
+ var added=false;
2278
+ if(allTags.length>0){
2279
+ var hT=document.createElement('div');hT.className='tag-menu-section';hT.textContent='Tags';menu.appendChild(hT);
2280
+ if(availTags.length===0){
2281
+ var emptyT=document.createElement('div');emptyT.className='tag-menu-empty';emptyT.textContent='All tags selected';menu.appendChild(emptyT);
2282
+ }else{
2283
+ availTags.forEach(function(t){
2284
+ var item=document.createElement('button');item.className='tag-menu-item';item.setAttribute('data-tag-menu-item',t);
2285
+ var label=document.createElement('span');label.textContent=t;
2286
+ var count=document.createElement('span');count.className='tag-menu-count';count.textContent=tagCount(t);
2287
+ item.appendChild(label);item.appendChild(count);
2288
+ menu.appendChild(item);
2289
+ });
2290
+ }
2291
+ added=true;
2292
+ }
2293
+ if(allReasons.length>0){
2294
+ var hR=document.createElement('div');hR.className='tag-menu-section';hR.textContent='Diagnoses';menu.appendChild(hR);
2295
+ if(availReasons.length===0){
2296
+ var emptyR=document.createElement('div');emptyR.className='tag-menu-empty';emptyR.textContent='All diagnoses selected';menu.appendChild(emptyR);
2297
+ }else{
2298
+ availReasons.forEach(function(r){
2299
+ var meta=REASON_LABELS[r]||REASON_LABELS['UNKNOWN'];
2300
+ var item=document.createElement('button');item.className='tag-menu-item';item.setAttribute('data-reason-menu-item',r);
2301
+ var label=document.createElement('span');label.textContent=meta.label;label.style.color=meta.color;
2302
+ var count=document.createElement('span');count.className='tag-menu-count';count.textContent=reasonCount(r);
2303
+ item.appendChild(label);item.appendChild(count);
2304
+ menu.appendChild(item);
2305
+ });
2306
+ }
2307
+ added=true;
2308
+ }
2309
+ if(!added){
2310
+ var empty=document.createElement('div');empty.className='tag-menu-empty';empty.textContent='No filters available';menu.appendChild(empty);
2311
+ }
2312
+ menu.hidden=false;
2313
+ if(trigger)trigger.classList.add('active');
2314
+ }
2315
+ function closeTagMenu(){
2316
+ var menu=document.querySelector('[data-tag-menu]');var trigger=document.querySelector('[data-add-tag-filter]');
2317
+ if(menu)menu.hidden=true;
2318
+ if(trigger)trigger.classList.remove('active');
2319
+ }
2320
+ function tagMenuOpen(){var m=document.querySelector('[data-tag-menu]');return !!(m&&!m.hidden)}
2321
+
2322
+ function clearAllFilters(){
2323
+ activeStatus=null;
2324
+ activeTags.clear();
2325
+ activeReasons.clear();
2135
2326
  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')});
2327
+ renderActiveChips();
2328
+ closeTagMenu();
2329
+ applyFilters();
2138
2330
  }
2331
+
2139
2332
  function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
2140
2333
 
2141
2334
  document.addEventListener('click',function(e){
@@ -2165,14 +2358,32 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2165
2358
  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
2359
  var jsonBtn=e.target.closest('.copy-json');
2167
2360
  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}
2361
+ // Tag filter: add (+ button toggles the menu) / select (menu item) /
2362
+ // remove (chip × button). Outside-clicks close the menu via the catch-all
2363
+ // at the end of this handler.
2364
+ var addTagBtn=e.target.closest('[data-add-tag-filter]');
2365
+ if(addTagBtn){if(tagMenuOpen()){closeTagMenu()}else{openTagMenu()}return}
2366
+ var tagItem=e.target.closest('[data-tag-menu-item]');
2367
+ if(tagItem){addTag(tagItem.getAttribute('data-tag-menu-item'));closeTagMenu();return}
2368
+ var reasonItem=e.target.closest('[data-reason-menu-item]');
2369
+ if(reasonItem){addReason(reasonItem.getAttribute('data-reason-menu-item'));closeTagMenu();return}
2370
+ var tagRemove=e.target.closest('[data-remove-tag]');
2371
+ if(tagRemove){removeTag(tagRemove.getAttribute('data-remove-tag'));return}
2372
+ var reasonRemove=e.target.closest('[data-remove-reason]');
2373
+ if(reasonRemove){removeReason(reasonRemove.getAttribute('data-remove-reason'));return}
2168
2374
  // Stat pill filter
2169
2375
  var pill=e.target.closest('.stat-pill[data-filter]');
2170
- if(pill){filterByStatus(pill.getAttribute('data-filter'));return}
2376
+ if(pill){toggleStatus(pill.getAttribute('data-filter'));return}
2171
2377
  // 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
2378
+ if(e.target.closest('[data-clear-filter]')){clearAllFilters();return}
2379
+ // Any other click closes the tag menu (outside-click dismiss).
2380
+ if(tagMenuOpen()&&!e.target.closest('[data-tag-menu]')){closeTagMenu()}
2381
+ // Test bar block click — scroll to and highlight the target test card,
2382
+ // and reflect the selection in location.hash so the URL is shareable.
2383
+ // replaceState (not pushState) keeps the back button useful after the
2384
+ // user has clicked through several bar blocks.
2174
2385
  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}
2386
+ 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
2387
  // Filmstrip step expand (skip if clicking a link inside).
2177
2388
  // <div>-based steps: toggle the open class on any click in the wrapper.
2178
2389
  // <details>-based steps: <summary> clicks are handled natively; padding
@@ -2192,18 +2403,98 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2192
2403
  // Audit check row expand
2193
2404
  var auditCheck=e.target.closest('.audit-check.expandable');
2194
2405
  if(auditCheck){auditCheck.classList.toggle('open');return}
2195
- // Test card expand
2406
+ // Test card expand — also reflect the selection in location.hash on
2407
+ // open so the URL is shareable. Same replaceState rationale as the bar
2408
+ // block handler above. Collapses leave the URL alone.
2196
2409
  var row=e.target.closest('.test-card[data-detail]');
2197
- if(row&&!e.target.closest('.test-detail')){row.classList.toggle('expanded');return}
2410
+ 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
2411
  });
2199
2412
 
2200
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
2413
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();closeTagMenu();clearAllFilters()}});
2201
2414
 
2202
2415
  // Auto-expand failed/timedout/interrupted/healed tests
2203
2416
  document.querySelectorAll('.test-card.failed,.test-card.timedout,.test-card.interrupted,.test-card.healed').forEach(function(r){r.classList.add('expanded')});
2417
+
2418
+ // Collect the cumulative set of tags + diagnosis reasons present across all
2419
+ // test cards. Reveal the +filter controls when at least one of either exists.
2420
+ (function(){
2421
+ var seenTags=Object.create(null);
2422
+ var seenReasons=Object.create(null);
2423
+ document.querySelectorAll('.test-card').forEach(function(card){
2424
+ var raw=card.getAttribute('data-tags');
2425
+ if(raw){try{var tags=JSON.parse(raw);if(Array.isArray(tags)){tags.forEach(function(t){if(typeof t==='string'&&t)seenTags[t]=true})}}catch(_){}}
2426
+ var r=card.getAttribute('data-reason');
2427
+ if(r)seenReasons[r]=true;
2428
+ });
2429
+ allTags=Object.keys(seenTags).sort();
2430
+ // Preserve the REASON_LABELS declaration order rather than alphabetical —
2431
+ // they're already arranged from most-frequent/specific to UNKNOWN catch-all.
2432
+ allReasons=Object.keys(REASON_LABELS).filter(function(r){return seenReasons[r]});
2433
+ var controls=document.querySelector('[data-tag-filter-controls]');
2434
+ if(controls&&(allTags.length>0||allReasons.length>0))controls.hidden=false;
2435
+ })();
2436
+
2437
+ // Seed filter state from ?status=...&tag=...&reason=... so shared URLs
2438
+ // restore the view. Status values not in the known stat-pill set are
2439
+ // ignored; tag and reason values not present in this report are dropped
2440
+ // so a stale URL can't poison the state.
2441
+ (function(){
2442
+ var p=new URLSearchParams(location.search);
2443
+ var s=p.get('status');
2444
+ var validStatuses={};
2445
+ document.querySelectorAll('.stat-pill[data-filter]').forEach(function(el){validStatuses[el.getAttribute('data-filter')]=true});
2446
+ if(s&&validStatuses[s]){
2447
+ activeStatus=s;
2448
+ document.querySelectorAll('.stat-pill').forEach(function(el){el.classList.toggle('active',el.getAttribute('data-filter')===s)});
2449
+ }
2450
+ var tagSet={};allTags.forEach(function(t){tagSet[t]=true});
2451
+ p.getAll('tag').forEach(function(t){if(tagSet[t])activeTags.add(t)});
2452
+ var reasonSet={};allReasons.forEach(function(r){reasonSet[r]=true});
2453
+ p.getAll('reason').forEach(function(r){if(reasonSet[r])activeReasons.add(r)});
2454
+ if(activeTags.size>0||activeReasons.size>0)renderActiveChips();
2455
+ if(activeStatus!==null||activeTags.size>0||activeReasons.size>0)applyFilters();
2456
+ })();
2457
+
2458
+ // Open #?testId=<id> deep links to the matching test card. Used by the
2459
+ // per-test redirect stubs (one per Playwright test-results directory) and
2460
+ // by any external link that wants to permalink a specific test.
2461
+ function focusTestFromHash(){
2462
+ var h=location.hash||'';
2463
+ if(h.indexOf('#?')!==0)return;
2464
+ var params=new URLSearchParams(h.slice(2));
2465
+ var id=params.get('testId');
2466
+ if(!id)return;
2467
+ var card=document.getElementById('test-'+id);
2468
+ if(!card||!card.classList.contains('test-card'))return;
2469
+ card.classList.add('expanded');
2470
+ card.scrollIntoView({behavior:'smooth',block:'center'});
2471
+ }
2472
+ focusTestFromHash();
2473
+ window.addEventListener('hashchange',focusTestFromHash);
2204
2474
  })();
2205
2475
  </script>
2206
2476
  </body>
2207
2477
  </html>`;
2208
2478
  }
2479
+ /**
2480
+ * Render the tiny redirect HTML that Donobu drops into each Playwright-managed
2481
+ * per-test directory under `test-results/`. The stub bounces straight to the
2482
+ * combined report's `#?testId=<id>` deep link — meta-refresh + JS replace +
2483
+ * visible fallback link, so it works with or without JS, online or `file://`.
2484
+ *
2485
+ * Strictly additive: Donobu does not create or rename Playwright's per-test
2486
+ * directories — the caller in `html.ts` only writes this file into directories
2487
+ * Playwright already created for the test's attachments.
2488
+ */
2489
+ function renderPerTestStub(params) {
2490
+ const { testId, title, relPathToReport } = params;
2491
+ const href = `${relPathToReport}#?testId=${encodeURIComponent(testId)}`;
2492
+ return `<!DOCTYPE html>
2493
+ <meta charset="UTF-8">
2494
+ <title>Donobu — ${esc(title)}</title>
2495
+ <meta http-equiv="refresh" content="0; url=${esc(href)}">
2496
+ <script>location.replace(${JSON.stringify(href)});</script>
2497
+ <p>Redirecting to <a href="${esc(href)}">test report</a>…</p>
2498
+ `;
2499
+ }
2209
2500
  //# 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.43.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",