donobu 5.41.3 → 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.
@@ -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,
@@ -577,18 +579,42 @@ function renderErrors(errors) {
577
579
  }
578
580
  return html;
579
581
  }
580
- function renderNativeStep(ns, childrenHtml) {
581
- const statusIcon = ns.passed
582
- ? '<span class="step-status-ok">&#10003;</span>'
583
- : '<span class="step-status-fail">&#10007;</span>';
584
- const categoryBadge = `<span class="native-step-badge native-step-badge--${ns.category}">${esc(ns.category)}</span>`;
582
+ function renderNativeStep(ns, childrenHtml, verifyContext = false) {
583
+ // Expects inside an assert tool's cache-worthiness verification window are
584
+ // not real assertion checks — they're AssertTool re-running its own
585
+ // AI-emitted structured `expect()` calls to decide whether to cache them.
586
+ // When one fails, the AI's screenshot-based verdict still stands; only the
587
+ // structured locator faithfulness is in question. Render those with a
588
+ // distinct status (passed → "verified", failed → "diverged") so they
589
+ // don't look like assertion failures sitting under a passing assertion.
590
+ const statusIcon = verifyContext
591
+ ? ns.passed
592
+ ? '<span class="step-status-verified" title="Cache-verify check passed">&#10003;</span>'
593
+ : '<span class="step-status-diverged" title="Cache-verify locator did not match the AI&#39;s visual verdict">&#10073;</span>'
594
+ : ns.passed
595
+ ? '<span class="step-status-ok">&#10003;</span>'
596
+ : '<span class="step-status-fail">&#10007;</span>';
597
+ const categoryLabel = verifyContext
598
+ ? ns.passed
599
+ ? 'verify-cache'
600
+ : 'verify-cache diverged'
601
+ : ns.category;
602
+ const categoryClass = verifyContext
603
+ ? ns.passed
604
+ ? 'native-step-badge--verify'
605
+ : 'native-step-badge--verify-diverged'
606
+ : `native-step-badge--${ns.category}`;
607
+ const categoryBadge = `<span class="native-step-badge ${categoryClass}">${esc(categoryLabel)}</span>`;
585
608
  const locationStr = ns.location?.file
586
609
  ? esc(`${ns.location.file.replace(/.*[/\\]/, '')}:${ns.location.line}`)
587
610
  : '';
588
611
  const snippet = ns.location?.file
589
612
  ? readSourceSnippet(ns.location.file, ns.location.line)
590
613
  : null;
591
- const hasError = !ns.passed && !!ns.error?.message;
614
+ // Cache-verify failures aren't surfaced as red errors; the message lives
615
+ // alongside the parent invocation's `cache · miss` pill instead. We still
616
+ // want the body open so the locator's call log is visible at a glance.
617
+ const hasError = !ns.passed && !!ns.error?.message && !verifyContext;
592
618
  const hasBody = !!snippet || hasError || !!childrenHtml;
593
619
  const renderHeader = (tag) => {
594
620
  let header = `<${tag} class="filmstrip-header">`;
@@ -609,9 +635,17 @@ function renderNativeStep(ns, childrenHtml) {
609
635
  // Failures always render expanded so the error is immediately visible.
610
636
  // test.step blocks with nested content also default open so users see
611
637
  // what's inside; bare passing expects with just a snippet collapse to
612
- // keep tests with many assertions scannable.
613
- const defaultOpen = !ns.passed || (ns.category === 'test.step' && !!childrenHtml);
614
- const passClass = ns.passed ? 'native-step--passed' : 'native-step--failed';
638
+ // keep tests with many assertions scannable. Cache-verify divergences
639
+ // are routine signal start collapsed so they don't dominate the view.
640
+ const defaultOpen = !verifyContext &&
641
+ (!ns.passed || (ns.category === 'test.step' && !!childrenHtml));
642
+ const passClass = verifyContext
643
+ ? ns.passed
644
+ ? 'native-step--verify'
645
+ : 'native-step--verify-diverged'
646
+ : ns.passed
647
+ ? 'native-step--passed'
648
+ : 'native-step--failed';
615
649
  let html = `<details class="filmstrip-step native-step expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
616
650
  html += renderHeader('summary');
617
651
  if (hasError) {
@@ -679,12 +713,31 @@ function renderAiInvocation(inv, childrenHtml) {
679
713
  ? '<span class="step-status-ok">&#10003;</span>'
680
714
  : '<span class="step-status-fail">&#10007;</span>';
681
715
  const kindBadge = `<span class="ai-invocation-badge ai-invocation-badge--${inv.kind}">${esc(AI_KIND_LABELS[inv.kind])}</span>`;
682
- const cachedBadge = inv.cacheHit
683
- ? '<span class="ai-cached-badge">cached</span>'
684
- : '';
716
+ const cacheState = inv.cacheHit
717
+ ? 'hit'
718
+ : inv.cacheStored
719
+ ? 'stored'
720
+ : 'miss';
721
+ const cacheLabel = {
722
+ hit: 'cache · hit',
723
+ stored: 'cache · stored',
724
+ miss: 'cache · miss',
725
+ };
726
+ const cacheTitle = {
727
+ hit: 'Replayed from the page-AI cache; no AI used for this step.',
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.',
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.",
730
+ };
731
+ const cacheBadge = `<span class="ai-cache-badge ai-cache-badge--${cacheState}" title="${esc(cacheTitle[cacheState])}">${cacheLabel[cacheState]}</span>`;
732
+ // For a passing assert whose structured-step verifier failed, surface
733
+ // *why* the cache outcome was `miss`. The header pill carries the
734
+ // at-a-glance signal; this body content is the technical detail.
735
+ // (When the assert itself failed, the regular failure path already
736
+ // covers it.)
737
+ const showVerifierDetail = inv.passed && inv.verification?.failed === true;
685
738
  const hasError = !inv.passed && !!inv.error?.message;
686
739
  const hasAssertSteps = !!inv.assertSteps && inv.assertSteps.length > 0;
687
- const hasBody = hasError || !!childrenHtml || hasAssertSteps;
740
+ const hasBody = hasError || !!childrenHtml || hasAssertSteps || showVerifierDetail;
688
741
  const renderHeader = (tag) => {
689
742
  let header = `<${tag} class="filmstrip-header">`;
690
743
  header +=
@@ -692,7 +745,7 @@ function renderAiInvocation(inv, childrenHtml) {
692
745
  header += statusIcon;
693
746
  header += `<span class="ai-invocation-title">${esc(inv.description)}</span>`;
694
747
  header += kindBadge;
695
- header += cachedBadge;
748
+ header += cacheBadge;
696
749
  header += `</${tag}>`;
697
750
  return header;
698
751
  };
@@ -706,13 +759,20 @@ function renderAiInvocation(inv, childrenHtml) {
706
759
  // by default so the contents are visible without an extra click.
707
760
  const defaultOpen = !inv.passed || !!childrenHtml || hasAssertSteps;
708
761
  const passClass = inv.passed
709
- ? 'ai-invocation--passed'
762
+ ? showVerifierDetail
763
+ ? 'ai-invocation--passed ai-invocation--cache-miss'
764
+ : 'ai-invocation--passed'
710
765
  : 'ai-invocation--failed';
711
766
  let html = `<details class="filmstrip-step ai-invocation expandable ${passClass}"${defaultOpen ? ' open' : ''}>`;
712
767
  html += renderHeader('summary');
713
768
  if (hasError) {
714
769
  html += `<pre class="native-step-error">${ansiToHtml(inv.error.message)}</pre>`;
715
770
  }
771
+ if (showVerifierDetail && inv.verification?.errorMessage) {
772
+ html +=
773
+ `<div class="ai-cache-miss-explainer">The AI&rsquo;s screenshot verdict (passed) is what counts. Its structured Playwright steps did not reproduce that verdict against the live page — most often an over-broad locator — so they were not cached. The diverging check is highlighted below.</div>` +
774
+ `<pre class="ai-cache-miss-detail">${ansiToHtml(inv.verification.errorMessage)}</pre>`;
775
+ }
716
776
  if (hasAssertSteps) {
717
777
  const lines = inv
718
778
  .assertSteps.map((s) => esc(formatAssertionStep(s)))
@@ -1070,6 +1130,28 @@ function renderSteps(steps, stepScreenshots, nativeSteps, aiInvocations, outputD
1070
1130
  }
1071
1131
  return c;
1072
1132
  };
1133
+ // A native step is part of an AssertTool cache-worthiness verification
1134
+ // (rather than a user-authored assertion) iff its time window falls
1135
+ // inside the `verification` window of some enclosing AI invocation.
1136
+ // `verifyWindows` is the ordered list of those windows; `inVerify`
1137
+ // checks membership without scanning the tree.
1138
+ const verifyWindows = [];
1139
+ for (const inv of aiInvocations) {
1140
+ if (inv.verification) {
1141
+ verifyWindows.push({
1142
+ start: inv.verification.startedAt,
1143
+ end: inv.verification.endedAt,
1144
+ });
1145
+ }
1146
+ }
1147
+ const inVerify = (t, tEnd) => {
1148
+ for (const w of verifyWindows) {
1149
+ if (t >= w.start && tEnd <= w.end) {
1150
+ return true;
1151
+ }
1152
+ }
1153
+ return false;
1154
+ };
1073
1155
  const renderNode = (node) => {
1074
1156
  if (node.kind === 'donobu') {
1075
1157
  return renderFilmstripStep(node.ss, outputDir);
@@ -1083,7 +1165,7 @@ function renderSteps(steps, stepScreenshots, nativeSteps, aiInvocations, outputD
1083
1165
  const childrenHtml = node.children.length > 0
1084
1166
  ? `<div class="native-step-children">${node.children.map(renderNode).join('')}</div>`
1085
1167
  : '';
1086
- return renderNativeStep(node.ns, childrenHtml);
1168
+ return renderNativeStep(node.ns, childrenHtml, inVerify(node.t, node.tEnd));
1087
1169
  };
1088
1170
  const stepCount = countNodes(roots);
1089
1171
  let html = '<details class="steps-section"><summary>Steps (' +
@@ -1450,8 +1532,10 @@ function renderHtml(report, triage, outputDir) {
1450
1532
  // Group by file
1451
1533
  const uniqueFiles = new Set(tests.map((t) => t.file));
1452
1534
  // --- Build HTML sections ---
1453
- // Pre-assign stable IDs for each test (used by both bar blocks and cards)
1454
- 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}`);
1455
1539
  // Build test bar blocks (one square per test, ordered by status, clickable)
1456
1540
  const statusOrder = [
1457
1541
  'passed',
@@ -1594,7 +1678,7 @@ function renderHtml(report, triage, outputDir) {
1594
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>`
1595
1679
  : '';
1596
1680
  testSectionsHtml += `
1597
- <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}"` : ''}>
1598
1682
  <div class="test-summary">
1599
1683
  ${chevron}
1600
1684
  <span class="status-dot" style="background:${sc.color}" title="${sc.label}"></span>
@@ -1606,7 +1690,7 @@ function renderHtml(report, triage, outputDir) {
1606
1690
  ${totalStepCount > 0 ? `<span class="test-step-count" title="${totalStepCount} steps">${totalStepCount} steps</span>` : ''}
1607
1691
  <span class="test-duration">${fmtDuration(totalTestDuration)}</span>
1608
1692
  </div>
1609
- ${hasDetails ? `<div class="test-detail" id="${testId}">${flowIdDetailHtml}${detailsHtml}</div>` : ''}
1693
+ ${hasDetails ? `<div class="test-detail" id="detail-${testId}">${flowIdDetailHtml}${detailsHtml}</div>` : ''}
1610
1694
  </div>`;
1611
1695
  }
1612
1696
  const mergedBanner = isMergedReport
@@ -1688,6 +1772,32 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1688
1772
  .clear-filter:hover{background:var(--surface-raised);border-color:var(--text-dim);color:var(--text)}
1689
1773
  .clear-filter.visible{display:flex}
1690
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
+
1691
1801
  /* Test cards */
1692
1802
  .test-card{background:var(--surface);border:1px solid var(--border-subtle);border-radius:var(--radius-lg);margin-bottom:10px;overflow:hidden}
1693
1803
  .test-card.failed,.test-card.timedout,.test-card.interrupted{border-color:rgba(239,68,68,.2)}
@@ -1781,6 +1891,8 @@ body::before{content:'';position:fixed;top:-750px;left:50%;transform:translateX(
1781
1891
  .filmstrip-summary{font-size:11px;color:var(--text-dim);margin-top:2px;padding-left:44px}
1782
1892
  .step-status-ok{color:var(--green);font-size:12px;font-weight:bold}
1783
1893
  .step-status-fail{color:var(--red);font-size:12px;font-weight:bold}
1894
+ .step-status-verified{color:#94a3b8;font-size:12px;font-weight:bold}
1895
+ .step-status-diverged{color:#fbbf24;font-size:14px;font-weight:bold;line-height:1}
1784
1896
  .filmstrip-detail{display:none;padding:8px 0 4px 44px;flex-direction:row;gap:12px;align-items:flex-start}
1785
1897
  .filmstrip-step.open .filmstrip-detail{display:flex}
1786
1898
  .filmstrip-detail>a{flex-shrink:0;max-width:50%}
@@ -1840,6 +1952,8 @@ details.native-step>summary::-webkit-details-marker{display:none}
1840
1952
  .native-step-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0}
1841
1953
  .native-step-badge--expect{background:rgba(99,102,241,.12);color:#818cf8}
1842
1954
  .native-step-badge--test\.step{background:rgba(16,185,129,.10);color:#34d399}
1955
+ .native-step-badge--verify{background:rgba(148,163,184,.12);color:#94a3b8}
1956
+ .native-step-badge--verify-diverged{background:rgba(245,158,11,.12);color:#fbbf24}
1843
1957
  .native-step-location{font-size:10px;color:var(--text-dim);font-family:var(--mono);margin-left:auto;flex-shrink:0;white-space:nowrap}
1844
1958
  details.native-step[open]>summary .native-step-chevron{transform:rotate(90deg)}
1845
1959
  .native-step-error{font-size:11px;font-family:var(--mono);padding:4px 0 2px 44px;margin:0;white-space:pre-wrap;word-break:break-word;color:var(--text-muted)}
@@ -1860,7 +1974,17 @@ details.ai-invocation>summary::-webkit-details-marker{display:none}
1860
1974
  .ai-invocation-badge--act{background:rgba(168,85,247,.12);color:#c084fc}
1861
1975
  .ai-invocation-badge--assert{background:rgba(236,72,153,.12);color:#f472b6}
1862
1976
  .ai-invocation-badge--locate{background:rgba(59,130,246,.12);color:#60a5fa}
1863
- .ai-cached-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0;background:rgba(245,158,11,.12);color:#fbbf24}
1977
+ .ai-cache-badge{font-size:10px;font-weight:600;padding:1px 5px;border-radius:3px;white-space:nowrap;flex-shrink:0;font-family:var(--mono);cursor:help}
1978
+ .ai-cache-badge--hit{background:rgba(59,130,246,.12);color:#60a5fa}
1979
+ .ai-cache-badge--stored{background:rgba(52,211,153,.12);color:#34d399}
1980
+ .ai-cache-badge--miss{background:rgba(245,158,11,.12);color:#fbbf24}
1981
+ .ai-cache-miss-explainer{font-size:11px;color:var(--text-muted);padding:4px 0 2px 44px;line-height:1.45}
1982
+ .ai-cache-miss-detail{font-size:11px;font-family:var(--mono);padding:4px 0 2px 44px;margin:0;white-space:pre-wrap;word-break:break-word;color:var(--text-dim)}
1983
+ .ai-invocation--cache-miss>summary{box-shadow:inset 3px 0 0 0 rgba(245,158,11,.6)}
1984
+ .native-step--verify .snippet-line--target{background:rgba(148,163,184,.10)}
1985
+ .native-step--verify .snippet-line--target .snippet-linenum{color:#94a3b8}
1986
+ .native-step--verify-diverged .snippet-line--target{background:rgba(245,158,11,.10)}
1987
+ .native-step--verify-diverged .snippet-line--target .snippet-linenum{color:#fbbf24}
1864
1988
  details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)}
1865
1989
  .ai-assert-steps{font-size:11px;font-family:var(--mono);background:var(--bg);border:1px solid var(--border-subtle);border-radius:var(--radius);padding:8px 12px;margin:6px 0 2px 44px;color:var(--text-muted);white-space:pre-wrap;word-break:break-word;overflow-x:auto;max-height:240px;overflow-y:auto}
1866
1990
  .snippet-line{display:flex;padding:1px 8px;white-space:pre}
@@ -2010,7 +2134,15 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2010
2134
  <div class="test-bar">${testBarHtml}</div>
2011
2135
  <div class="summary-stats">
2012
2136
  <div class="stat-pills">${statPillsHtml}</div>
2013
- <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>
2014
2146
  </div>
2015
2147
  </div>
2016
2148
 
@@ -2028,20 +2160,113 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2028
2160
 
2029
2161
  <script>
2030
2162
  (function(){
2031
- var activeFilter=null;
2032
- function filterByStatus(s){
2033
- if(activeFilter===s){clearFilter();return}
2034
- activeFilter=s;
2035
- document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.toggle('active',p.getAttribute('data-filter')===s)});
2036
- document.querySelector('.clear-filter').classList.add('visible');
2037
- 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();
2038
2195
  }
2039
- function clearFilter(){
2040
- activeFilter=null;
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
+ });
2228
+ }
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();
2041
2264
  document.querySelectorAll('.stat-pill').forEach(function(p){p.classList.remove('active')});
2042
- document.querySelector('.clear-filter').classList.remove('visible');
2043
- document.querySelectorAll('.test-card').forEach(function(r){r.classList.remove('hidden-by-filter')});
2265
+ renderActiveChips();
2266
+ closeTagMenu();
2267
+ applyFilters();
2044
2268
  }
2269
+
2045
2270
  function closeLightbox(){document.getElementById('lightbox').classList.remove('open')}
2046
2271
 
2047
2272
  document.addEventListener('click',function(e){
@@ -2071,14 +2296,28 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2071
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}
2072
2297
  var jsonBtn=e.target.closest('.copy-json');
2073
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}
2074
2308
  // Stat pill filter
2075
2309
  var pill=e.target.closest('.stat-pill[data-filter]');
2076
- if(pill){filterByStatus(pill.getAttribute('data-filter'));return}
2310
+ if(pill){toggleStatus(pill.getAttribute('data-filter'));return}
2077
2311
  // Clear filter
2078
- if(e.target.closest('[data-clear-filter]')){clearFilter();return}
2079
- // 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.
2080
2319
  var barBlock=e.target.closest('.test-bar-block[data-target]');
2081
- 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}
2082
2321
  // Filmstrip step expand (skip if clicking a link inside).
2083
2322
  // <div>-based steps: toggle the open class on any click in the wrapper.
2084
2323
  // <details>-based steps: <summary> clicks are handled natively; padding
@@ -2098,18 +2337,90 @@ details.ai-invocation[open]>summary .native-step-chevron{transform:rotate(90deg)
2098
2337
  // Audit check row expand
2099
2338
  var auditCheck=e.target.closest('.audit-check.expandable');
2100
2339
  if(auditCheck){auditCheck.classList.toggle('open');return}
2101
- // 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.
2102
2343
  var row=e.target.closest('.test-card[data-detail]');
2103
- 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}
2104
2345
  });
2105
2346
 
2106
- document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();clearFilter()}});
2347
+ document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeLightbox();closeTagMenu();clearAllFilters()}});
2107
2348
 
2108
2349
  // Auto-expand failed/timedout/interrupted/healed tests
2109
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);
2110
2400
  })();
2111
2401
  </script>
2112
2402
  </body>
2113
2403
  </html>`;
2114
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
+ }
2115
2426
  //# sourceMappingURL=render.js.map
@@ -207,18 +207,34 @@ careful positioning lost, etc. A screenshot of the webpage has also been provide
207
207
  // When the AI assertion passes and structured steps were returned,
208
208
  // verify the steps against the live page before considering them
209
209
  // cacheable. If the steps fail, discard them but still return the
210
- // passing AI result.
210
+ // passing AI result. The verification window is recorded so the HTML
211
+ // reporter can label its `expect()` calls as cache-worthiness checks
212
+ // rather than treating an internal locator mismatch as an assertion
213
+ // failure.
211
214
  let verifiedSteps = assertionOutcome.output.playwrightAssertionSteps;
215
+ let verification;
212
216
  if (assertPassed &&
213
217
  Array.isArray(verifiedSteps) &&
214
218
  verifiedSteps.length > 0) {
219
+ const verifyStartedAt = Date.now();
215
220
  try {
216
221
  const executor = (0, assertCache_1.buildAssertExecutor)(verifiedSteps);
217
222
  await executor({ page: page, envData: context.envData });
223
+ verification = {
224
+ startedAt: verifyStartedAt,
225
+ endedAt: Date.now(),
226
+ failed: false,
227
+ };
218
228
  }
219
229
  catch (error) {
220
230
  Logger_1.appLogger.debug(`Structured assertion steps failed verification for: "${parameters.assertionToTestFor}" — discarding steps. Error: ${error.message}`);
221
231
  verifiedSteps = null;
232
+ verification = {
233
+ startedAt: verifyStartedAt,
234
+ endedAt: Date.now(),
235
+ failed: true,
236
+ errorMessage: error.message,
237
+ };
222
238
  }
223
239
  }
224
240
  const result = {
@@ -227,6 +243,7 @@ careful positioning lost, etc. A screenshot of the webpage has also been provide
227
243
  metadata: {
228
244
  ...assertionOutcome.output,
229
245
  playwrightAssertionSteps: verifiedSteps,
246
+ verification,
230
247
  attempt: attempt + 1,
231
248
  },
232
249
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "donobu",
3
- "version": "5.41.3",
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",