reporting-labs 0.6.10 → 0.6.12

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.
@@ -344,6 +344,10 @@ li.collapsed>ul{display:none}
344
344
  .wk .row{display:grid;grid-template-columns:36px 1fr 120px;gap:10px;align-items:center;font-size:12px}
345
345
  .wk .k{font:12px var(--mono);color:var(--ink-3)} .wk .bar{height:12px;display:flex;border-radius:3px;overflow:hidden;background:var(--skip-bg)} .wk .bar i{display:block;height:100%}
346
346
  .wk .n{font:11px var(--mono);color:var(--ink-3);text-align:right}
347
+ .wk .row{grid-template-columns:36px 1fr auto} .wk .n{white-space:nowrap}
348
+ .proj-sfx{font-weight:400;color:var(--ink-3);font-size:.9em}
349
+ /* the Breakdown card keeps its own height: a two-priority suite should not leave a tall empty card */
350
+ .bd-card{align-self:start}
347
351
  .wk-sum{font-size:13px;color:var(--ink-2);margin-bottom:10px}
348
352
  /* list: group toggle + folder tree */
349
353
  .tools .seg,.bug-tools .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
@@ -379,7 +383,9 @@ table.tbl{border-collapse:collapse;font-size:12.5px;width:100%;min-width:100%}
379
383
  .api-head .m{font:11px/1 var(--mono);font-weight:600;padding:4px 7px;border-radius:3px;color:#fff;background:var(--ink-3)}
380
384
  .api-head .m.GET{background:#2A7FBF} .api-head .m.POST{background:var(--pass)} .api-head .m.PUT,.api-head .m.PATCH{background:#C77D14} .api-head .m.DELETE{background:var(--fail)}
381
385
  .api-head .u{flex:1;font:12px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
382
- .api-head .sc{font:12px var(--mono);font-weight:600} .api-head .sc.ok{color:var(--pass)} .api-head .sc.bad{color:var(--fail)} .api-head .sc.warn{color:#9A6A00}
386
+ .api-head .sc{font:12px var(--mono);font-weight:600} .api-head .sc.ok{color:var(--pass)} .api-head .sc.bad{color:var(--fail)} .api-head .sc.warn{color:#9A6A00} .api-head .sc.exp{color:var(--ink-2)}
387
+ .sc-exp{margin-left:6px;font:500 10.5px var(--sans);color:var(--ink-3);background:var(--surface-2);border:1px solid var(--line);border-radius:999px;padding:1px 7px;vertical-align:1px}
388
+ .api-stats .exp b{color:var(--ink-2)} .api-proj{color:var(--ink-3)}
383
389
  .api-head .d{font:11px var(--mono);color:var(--ink-3)}
384
390
  .api-body{display:grid;grid-template-columns:1fr 1fr;gap:0}
385
391
  @media (max-width:900px){.api-body{grid-template-columns:1fr}}
@@ -559,13 +565,15 @@ html,body{-webkit-font-smoothing:antialiased}
559
565
  .runbanner{margin-bottom:14px;padding:10px 14px;border-radius:10px;background:var(--flaky-bg);color:var(--flaky-ink);border:1px solid color-mix(in srgb,var(--flaky) 40%,transparent)}
560
566
  .note{padding:10px 14px;border-radius:8px;margin-bottom:10px;border:1px solid var(--line)} .note.ok{background:var(--pass-bg);color:var(--ink)} .note.warn{background:var(--flaky-bg);color:var(--flaky-ink)}
561
567
  .badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
568
+ .xp{font-weight:500} .xp.all{color:var(--ink)} .xp.some{color:var(--flaky-ink)}
569
+ .badge.xp{background:var(--surface-2);border:1px solid var(--line)} .badge.xp.some{background:var(--flaky-bg);border-color:transparent}
562
570
  .errwrap+.errwrap{margin-top:14px}
563
571
  .why{border:1px solid var(--line);border-radius:var(--radius);padding:10px 14px;margin-bottom:10px;background:var(--surface)}
564
572
  .why-c{font-size:12.5px;color:var(--ink);margin-bottom:2px;line-height:1.4;overflow-wrap:anywhere} .why-c .why-k{margin-right:2px}
565
573
  .why-l{display:flex;align-items:center;gap:8px;margin-bottom:4px} .why-k{font-size:12px;font-weight:600;color:var(--fail)} .why-a{font:11.5px var(--mono);color:var(--ink-3)}
566
574
  .why-s{font-size:var(--fs);color:var(--ink);line-height:1.45;overflow-wrap:anywhere} .why-h{font-size:12.5px;color:var(--ink-2);margin-top:4px;line-height:1.45;overflow-wrap:anywhere}
567
575
  .why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
568
- .why.script .why-k,.why.thrown .why-k,.why.file .why-k{color:var(--ink-2)}
576
+ .why.script .why-k,.why.thrown .why-k,.why.file .why-k,.why.crashed .why-k{color:var(--ink-2)}
569
577
  .errloc{font-family:var(--mono);color:var(--ink-3);margin-bottom:6px} .errloc a{color:var(--accent-2);text-decoration:none} .errloc a:hover{text-decoration:underline}
570
578
  .snip{margin:10px 0 0;background:var(--surface-2);border:1px solid var(--line);border-radius:8px;padding:10px 12px;font-family:var(--mono);line-height:1.55;white-space:pre;overflow:auto;color:var(--ink-2)}
571
579
  /* ---------- triage extras: history, diff, export, env, heatmap ---------- */
@@ -647,6 +655,104 @@ html,body{font-size:var(--fs)}
647
655
  .nav .cnt,.chip,.step .cat,.folder,.item .d,.dim .k,.wk .n,.wk-sum,.attn2 .pr,.clu2 .n small,.flk .m,.own .cnts,.ediff .lbl,.ttip .tt-h span,.tags button,.hint-kbd,.trace-how,.api-col h5,.meta .k,.kv.env dt,.apitbl th,.hm thead th,.hm td button{font-family:var(--sans)}
648
656
  .chip b,.loc,.file,.file-row,.kbd,.slow .d,.step .d,.hist .l,.wk .k,.tbl td,.kv2 dd,.kv.env dd,.logs,.txt,.err,.ediff .val,.ediff.lines,.clu2 .msg,.apitbl .u,.api-head .m,.api-head .sc,.api-head .d,.api-col pre,.badge.tag,.trend text,.tl text,.attn2 .tt .s .mono,.trace-how code{font-family:var(--mono)}
649
657
  .chip{font-family:var(--sans)} .chip b{font-weight:500}
658
+
659
+ /* ---------- report footer: reportingLabs branding ---------- */
660
+ .rl-foot{max-width:1400px;margin:30px auto 0;padding:18px 28px 30px;border-top:1px solid var(--line);display:flex;align-items:center;gap:10px;color:var(--ink-3);font-size:12.5px}
661
+ .rl-foot-mark{width:22px;height:22px;display:inline-flex;flex:none}
662
+ .rl-foot-mark svg{width:22px;height:22px;display:block;border-radius:6px}
663
+ .rl-foot-txt b{color:var(--ink-2);font-weight:600}
664
+ @media print{ .rl-foot{display:none} }
665
+
666
+ /* ---------- print / PDF: a clean, client-shareable executive report ----------
667
+ Page 1 is a full executive summary (no wasted space); analytics, failure analysis, the
668
+ full test-case table and failure evidence each start on a fresh page. Per-test steps,
669
+ logs, API and console are left out so the PDF stays small and readable at 500+ tests. */
670
+ .printdoc{display:none}
671
+ @media print{
672
+ @page{ size:A4; margin:12mm 11mm 13mm; }
673
+ html,body{background:#fff!important}
674
+ #app{display:none!important}
675
+ .printdoc{display:block!important;max-width:none;margin:0;padding:0;color:var(--ink);font-size:12px}
676
+ .printdoc *{ -webkit-print-color-adjust:exact!important; print-color-adjust:exact!important; }
677
+ /* On paper nothing can be clicked or hovered, so never truncate text with an ellipsis or a
678
+ height clamp — wrap it and show it in full. Numbers, durations and status chips stay on one line. */
679
+ .printdoc .apitbl .u,.printdoc .apitbl .t,.printdoc .apitbl td,.printdoc .slow .t,.printdoc .proj .name,
680
+ .printdoc .file,.printdoc .item .tt .p,.printdoc .item .tt .n,.printdoc .item .d,.printdoc .step .t,
681
+ .printdoc .att figcaption,.printdoc .dim .k,.printdoc .attn .t,.printdoc .attn .t .tt,.printdoc .attn .sev,
682
+ .printdoc .attn2 .pr,.printdoc .attn2 .ow,.printdoc .attn2 .tt .n,.printdoc .attn2 .tt .s,
683
+ .printdoc .tbl td,.printdoc .tbl th,.printdoc .api-head .u,.printdoc .clu .msg,.printdoc .clu .n,
684
+ .printdoc .clu2 .msg,.printdoc .clu2 .who,.printdoc .clu2 .who button,.printdoc .own .ts,.printdoc .own .nm,
685
+ .printdoc .hist .b .l,.printdoc .loc,.printdoc .crumb span,.printdoc .pt-name,.printdoc .pt-sub,.printdoc .pt-spec{
686
+ white-space:normal!important;overflow:visible!important;text-overflow:clip!important;
687
+ max-width:none!important;max-height:none!important;overflow-wrap:break-word;-webkit-line-clamp:none!important;
688
+ }
689
+ /* durations / counts / status stay on one line so "9ms" never breaks to "9m s" */
690
+ .printdoc .apitbl td.t:last-child,.printdoc .num,.printdoc .pstatus,.printdoc .since-txt{white-space:nowrap!important;overflow-wrap:normal!important}
691
+ /* only genuinely unbreakable strings (urls, file paths, selectors) may break mid-token */
692
+ .printdoc .apitbl .u,.printdoc .api-head .u,.printdoc .pt-spec,.printdoc .loc,.printdoc .file,.printdoc .clu .msg,.printdoc .clu2 .msg{overflow-wrap:anywhere}
693
+ /* Content flows continuously — no forced page breaks between sections, which were leaving
694
+ whole pages nearly empty. Cards/rows just avoid being split across a page. */
695
+ .printdoc .psec{padding-top:0;margin-top:18px}
696
+ .printdoc .psec-h{font-size:16px;font-weight:600;letter-spacing:-.01em;margin:0 0 12px;padding-bottom:8px;border-bottom:2px solid var(--line);color:var(--ink);break-after:avoid}
697
+ /* Only small, atomic blocks avoid a page split. Big cards (clusters, needs-attention, the tables)
698
+ may flow across pages — keeping them whole is what left pages half-empty. */
699
+ .printdoc .gchart,.printdoc figure,.printdoc .kpi,.printdoc .hm,.printdoc .pev,.printdoc .pexec-card,.printdoc tr{break-inside:avoid}
700
+ .printdoc h4{break-after:avoid}
701
+ /* CSS grid does not fragment across print pages, so a tall two-column grid jumps to the next page
702
+ and leaves a gap. In print everything becomes a single full-width column that flows naturally. */
703
+ .printdoc .grid{display:block!important;margin:0}
704
+ .printdoc .grid>*{grid-column:auto!important;width:auto!important;max-width:none!important;margin:0 0 12px!important;break-inside:avoid}
705
+ .printdoc .grid>.card.w12,.printdoc .grid>.fail-rail,.printdoc .grid>.fx-tools{break-inside:auto}
706
+ .printdoc .graphs-grid>*{break-inside:avoid}
707
+ .printdoc .list,.printdoc .nav,.printdoc .band,.printdoc .stripe,.printdoc .fx-tools .btn,.printdoc .fx-tools .spacer{display:none!important}
708
+ /* drop interactive-only chrome and anything that cannot live on paper */
709
+ .printdoc .actions,.printdoc .gchart-head button,.printdoc .vids,.printdoc .cmp,.printdoc .hint-kbd,.printdoc .mask-note,.printdoc .clu2 .who .ex{display:none!important}
710
+ .printdoc a{color:var(--ink);text-decoration:none}
711
+ /* ---- cover / executive summary (page 1) ---- */
712
+ .printdoc .pcover{padding:2mm 0 0}
713
+ .printdoc .pcover-top{display:flex;align-items:center;gap:13px}
714
+ .printdoc .pcover .logo{width:44px;height:44px;flex:none}
715
+ .printdoc .pcover .logo svg{width:44px;height:44px;display:block;border-radius:11px}
716
+ .printdoc .pcover-title{font-size:25px;font-weight:600;letter-spacing:-.02em;line-height:1.1}
717
+ .printdoc .pcover-sub{color:var(--ink-3);font-size:12px;margin-top:3px}
718
+ .printdoc .pcover-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
719
+ .printdoc .pverdict{font-size:17px;font-weight:600;margin:16px 0 2px}
720
+ .printdoc .pverdict.ok{color:var(--pass)} .printdoc .pverdict.bad{color:var(--fail)}
721
+ .printdoc .pcover-stats{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 4px}
722
+ .printdoc .pstat{border:1px solid var(--line);border-radius:10px;padding:9px 15px;min-width:84px;background:var(--surface)}
723
+ .printdoc .pstat .n{font-size:25px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
724
+ .printdoc .pstat .l{font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-top:3px}
725
+ .printdoc .pexec{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px;align-items:start}
726
+ .printdoc .pexec-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface)}
727
+ .printdoc .psub-h{font-size:12.5px;font-weight:600;color:var(--ink);margin:0 0 10px}
728
+ /* ---- the all-tests table ---- */
729
+ .printdoc .ptbl{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}
730
+ .printdoc .ptbl thead{display:table-header-group}
731
+ .printdoc .ptbl th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--ink-3);font-weight:600;padding:7px 8px;border-bottom:2px solid var(--line)}
732
+ .printdoc .ptbl td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
733
+ .printdoc .ptbl td.num{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink-2);white-space:nowrap}
734
+ .printdoc .ptbl .pt-name{font-weight:500;color:var(--ink)}
735
+ .printdoc .ptbl .pt-sub{font-size:10px;color:var(--ink-3);margin-top:1px}
736
+ .printdoc .ptbl .pt-spec{font:10.5px var(--mono);color:var(--ink-2)}
737
+ .printdoc .pstatus{display:inline-flex;align-items:center;gap:6px;font-weight:500;white-space:nowrap}
738
+ .printdoc .pstatus .st{width:9px;height:9px;border-radius:50%;flex:none}
739
+ .printdoc .ptbl .c-n{width:30px} .printdoc .ptbl .c-pri{width:72px} .printdoc .ptbl .c-own{width:84px} .printdoc .ptbl .c-st{width:88px} .printdoc .ptbl .c-dur{width:62px}
740
+ /* ---- failure evidence ---- */
741
+ .printdoc .pev{padding:14px 0;border-top:1px solid var(--line)}
742
+ .printdoc .pev:first-child{border-top:0;padding-top:2px}
743
+ .printdoc .pev-head{display:flex;align-items:center;gap:9px}
744
+ .printdoc .pev-head .st{width:10px;height:10px;border-radius:50%;flex:none}
745
+ .printdoc .pev-title{font-size:14px;font-weight:600;letter-spacing:-.01em}
746
+ .printdoc .pev-loc{font:11px var(--mono);color:var(--ink-3);margin:3px 0 8px}
747
+ .printdoc .pev .why{margin-bottom:8px}
748
+ .printdoc .pev .att{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
749
+ .printdoc .pev .att figure{flex:1 1 320px;min-width:0;margin:0}
750
+ .printdoc .pev .att img{width:100%;max-height:115mm;object-fit:contain;border:1px solid var(--line);border-radius:6px;background:#fff}
751
+ .printdoc .pev .att figcaption{font:11px var(--mono);color:var(--ink-3);padding:5px 7px;background:var(--surface-2);border-radius:0 0 6px 6px;margin-top:-6px}
752
+ .printdoc .pev .errfull,.printdoc .pev details{display:none!important}
753
+ .printdoc .pfoot{margin-top:5mm;padding-top:3mm;border-top:1px solid var(--line);font-size:10px;color:var(--ink-3)}
754
+ .printdoc .err,.printdoc pre{white-space:pre-wrap;word-break:break-word}
755
+ }
650
756
  __RL_ACCENT_CSS____RL_CUSTOM_CSS__</style>
651
757
  </head>
652
758
  <body>
@@ -667,6 +773,14 @@ const HIST = data.history||[];
667
773
  const HRUNS = HIST.filter(e=>e&&e.tests); // entries that carry per-test outcomes; the last one is this run
668
774
  const PREV = HRUNS.length>1 ? HRUNS.slice(0,-1) : []; // earlier runs only
669
775
  const runLabel = e => e.label || new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
776
+ /* Run-level trends (Trend card, KPI sparklines and "from last run", pass rate by priority) only use full-size
777
+ runs. A debug run of one spec or a --grep shares the history file with the full suite; a failing 1-test run
778
+ would read as a 0% day. A run is "small" when it ran fewer than half the median size of the recorded runs.
779
+ Per-test history (dots, flaky, new vs known) still uses every run: there a run only counts for the tests in it. */
780
+ const MED_SIZE = (()=>{ const z=HIST.map(e=>e.total||0).sort((a,b)=>a-b); return z.length? z[Math.floor(z.length/2)] : 0; })();
781
+ const isSmallRun = e => HIST.length>=3 && (e.total||0) < MED_SIZE/2;
782
+ const TREND = (()=>{ const f=HIST.filter((e,i)=>!isSmallRun(e)); return f.length>=2? f : HIST; })();
783
+ const SMALL_HIDDEN = HIST.length - TREND.length;
670
784
 
671
785
  const state = { q:'', status:'all', project:'all', dims:{}, selected:null, retry:null, group:'file', open:{}, view:'overview' };
672
786
  const DIMS = data.options.dimensions.filter(d=>data.tests.some(t=>t.meta[d]));
@@ -684,7 +798,8 @@ app.append(h('div',{class:'band'}, stripe(), header(), nav()),
684
798
  h('div',{class:'view','data-view':'failures'}, h('div',{class:'view-pad'}, failuresView())),
685
799
  h('div',{class:'view','data-view':'api'}, h('div',{class:'view-pad'}, apiView())),
686
800
  h('div',{class:'view','data-view':'graphs'}, graphsView()),
687
- hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null);
801
+ hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null,
802
+ rlFooter());
688
803
  select(state.selected || firstInteresting(), true);
689
804
  showView(state.view);
690
805
  window.addEventListener('keydown', e=>{
@@ -727,17 +842,20 @@ function failuresView(){
727
842
  h('td',{class:'t'},t.file.split('/').pop()+(data.projects.length>1?' · '+t.project:'')), h('td',{},[t.meta.priority,t.meta.severity].filter(Boolean).join(' · ')), h('td',{},t.meta.owner||''), h('td',{},t.meta.story||t.meta.issue||''), h('td',{},t.results.length), PREV.length?h('td',{},(()=>{ const si=sinceInfo(t); return si? h('span',{class:'since-txt '+si.kind}, si.kind==='new'?'new':runLabel(si.since)) : h('span',{class:'t'},'–'); })()):null, HRUNS.length>1?h('td',{},dots(t,10)):null, h('td',{class:'t'},ms(t.duration))); })))));
728
843
  return grid;
729
844
  }
845
+ /** A 4xx/5xx is a failure only when the attempt that made the call failed. In a passing attempt it is the
846
+ * answer the test asked for (a 404 after a DELETE, a 401 for a bad token) — shown as "expected", never red. */
847
+ function apiCls(c,r){ const st=c.status||0; return st>=400 ? (r&&r.status==='passed'?'exp':'bad') : st>=300?'warn':'ok'; }
730
848
  function apiView(){
731
- const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r});
849
+ const calls=[]; for(const t of data.tests) for(const r of t.results) for(const c of r.api) calls.push({c,t,r,cls:apiCls(c,r)});
732
850
  if(!calls.length) return h('div',{class:'card'},h('b',{},'No API calls recorded.'),' Add ',h('code',{},"import 'reporting-labs/auto'"),' to playwright.config.ts and every request.get / request.post / page.request call shows up here with headers, bodies and a cURL command. Calls made with other clients can be recorded with api().');
733
- const bad=calls.filter(x=>(x.c.status||0)>=400).length, avg=Math.round(calls.reduce((a,x)=>a+(x.c.duration||0),0)/calls.length), slow=calls.filter(x=>(x.c.duration||0)>1000).length;
734
- calls.sort((a,b)=>((b.c.status||0)>=400)-((a.c.status||0)>=400)||(b.c.duration||0)-(a.c.duration||0));
851
+ const bad=calls.filter(x=>x.cls==='bad').length, expd=calls.filter(x=>x.cls==='exp').length, avg=Math.round(calls.reduce((a,x)=>a+(x.c.duration||0),0)/calls.length), slow=calls.filter(x=>(x.c.duration||0)>1000).length;
852
+ calls.sort((a,b)=>(b.cls==='bad')-(a.cls==='bad')||(b.c.duration||0)-(a.c.duration||0));
735
853
  const byHost=new Map(); for(const x of calls){ try{ const hst=new URL(x.c.url).host; byHost.set(hst,(byHost.get(hst)||0)+1);}catch(e){} }
736
- return h('div',{}, h('div',{class:'api-stats'}, h('span',{},h('b',{},calls.length),' calls'), h('span',{class:bad?'bad':''},h('b',{},bad),' failed (4xx/5xx)'), h('span',{},h('b',{},ms(avg)),' avg'), h('span',{},h('b',{},slow),' over 1s'), h('span',{},h('b',{},byHost.size),' host'+(byHost.size===1?'':'s'))),
854
+ return h('div',{}, h('div',{class:'api-stats'}, h('span',{},h('b',{},calls.length),' calls'), h('span',{class:bad?'bad':'',title:'4xx / 5xx responses in tests that failed'},h('b',{},bad),' failed (4xx/5xx)'), expd?h('span',{class:'exp',title:'4xx / 5xx responses in tests that passed: the answer the test expected, e.g. a 404 after a DELETE'},h('b',{},expd),' expected 4xx/5xx'):null, h('span',{},h('b',{},ms(avg)),' avg'), h('span',{},h('b',{},slow),' over 1s'), h('span',{},h('b',{},byHost.size),' host'+(byHost.size===1?'':'s'))),
737
855
  h('div',{class:'card w12'}, h('table',{class:'apitbl'}, h('thead',{}, h('tr',{}, ['Method','URL','Status','Time','Test'].map(x=>h('th',{},x)))),
738
- h('tbody',{}, calls.map(({c,t})=>{ const st=c.status||0, cls=st>=400?'bad':st>=300?'warn':'ok'; return h('tr',{onclick:()=>select(t.id)},
856
+ h('tbody',{}, calls.map(({c,t,cls})=>{ const st=c.status||0; return h('tr',{onclick:()=>select(t.id)},
739
857
  h('td',{}, h('span',{class:'m '+c.method.toUpperCase(),style:'font:11px var(--mono);font-weight:600;padding:3px 6px;border-radius:3px;color:#fff;background:'+({GET:'#2A7FBF',POST:'var(--pass)',PUT:'#C77D14',PATCH:'#C77D14',DELETE:'var(--fail)'}[c.method.toUpperCase()]||'var(--ink-3)')},c.method.toUpperCase())),
740
- h('td',{class:'u',title:c.url},c.url), h('td',{}, h('span',{class:'sc '+cls,style:'font:12px var(--mono);font-weight:600;color:'+(cls==='bad'?'var(--fail)':cls==='warn'?'var(--flaky-ink)':'var(--pass)')},st||'—')), h('td',{class:'t'},c.duration!=null?ms(c.duration):'—'), h('td',{class:'t',title:t.title},t.title)); })))));
858
+ h('td',{class:'u',title:c.url},c.url), h('td',{}, h('span',{class:'sc '+cls,style:'font:12px var(--mono);font-weight:600;color:'+(cls==='bad'?'var(--fail)':cls==='warn'?'var(--flaky-ink)':cls==='exp'?'var(--ink-2)':'var(--pass)'),title:cls==='exp'?'The test passed: this is the response it expected':''},st||'—'), cls==='exp'?h('span',{class:'sc-exp'},'expected'):null), h('td',{class:'t'},c.duration!=null?ms(c.duration):'—'), h('td',{class:'t',title:t.title},t.title, data.projects.length>1?h('span',{class:'api-proj'},' · '+t.project):null)); })))));
741
859
  }
742
860
  function timelineView(){
743
861
  const W=data.options.widgets, cards=[];
@@ -753,8 +871,9 @@ function graphsView(){
753
871
  return h('div',{class:'view-pad'},
754
872
  h('div',{class:'grid graphs-grid'},
755
873
  chartCard('outcomes-by-priority', 'Outcomes by priority', 'Stacked pass / fail / flaky / skipped for each priority tier.', chartPriorityOutcome),
874
+ data.projects.length>1? chartCard('outcomes-by-project', 'Outcomes by project', 'Pass / fail / flaky / skipped and the pass rate for each project (browser, device).', chartProjectOutcome) : null,
756
875
  chartCard('failure-categories', 'Failure categories', 'What kinds of failures are hurting the suite.', chartFailureCategories),
757
- chartCard('duration-distribution','Duration distribution', 'How test durations are spread across the run.', chartDurationHistogram),
876
+ chartCard('duration-distribution','Duration distribution', 'How the durations of the tests that ran are spread.', chartDurationHistogram),
758
877
  chartCard('trend-by-priority', 'Pass rate trend by priority', 'How each priority tier trends over the last runs.', chartTrendByPriority),
759
878
  chartCard('owner-leaderboard', 'Owner leaderboard', 'Failures and flakes ranked by owner. Add meta({owner:...}) to see this.', chartOwnerLeaderboard),
760
879
  ));
@@ -809,6 +928,29 @@ function chartCard(id, title, sub, chartFn){
809
928
  }
810
929
 
811
930
  /* Chart 1: horizontal stacked bar per priority (P0..P4 in order, plus 'no priority') */
931
+ /* Chart 1b: the same stacked bars per Playwright project (chromium, firefox, Microsoft Edge...), with each
932
+ project's pass rate, so a browser that fails more than the others stands out. */
933
+ function chartProjectOutcome(){
934
+ if(data.projects.length<2) return null;
935
+ const outs=['passed','flaky','failed','timedOut','interrupted','skipped'];
936
+ const colors={passed:'var(--pass)', flaky:'var(--flaky)', failed:'var(--fail)', timedOut:'var(--fail)', interrupted:'var(--fail)', skipped:'var(--skip)'};
937
+ const rows=data.projects.map(p=>{ const m={}; for(const o of outs) m[o]=0; for(const t of data.tests) if(t.project===p) m[t.outcome]=(m[t.outcome]||0)+1; return {p,m}; }).filter(r=>outs.some(o=>r.m[o]));
938
+ if(!rows.length) return null;
939
+ const tot=r=>outs.reduce((a,o)=>a+r.m[o],0), maxN=Math.max(...rows.map(tot));
940
+ const longest=Math.max(...rows.map(r=>r.p.length));
941
+ const W=520, rowH=28, gap=10, labW=Math.min(170,Math.max(70,longest*7+10)), barW=W-labW-96;
942
+ const H=rows.length*(rowH+gap)+30;
943
+ const parts=rows.map((r,i)=>{
944
+ const y=i*(rowH+gap)+18, n=tot(r), ran=n-r.m.skipped, rate=ran?Math.round(r.m.passed/ran*100):null, scale=barW/maxN;
945
+ let x=labW; const segs=[];
946
+ for(const o of outs){ const w=r.m[o]*scale; if(w<=0) continue; segs.push('<rect x="'+x+'" y="'+y+'" width="'+w+'" height="'+rowH+'" fill="'+colors[o]+'" data-outcome="'+o+'"><title>'+gEsc(r.p)+' · '+label[o]+' '+r.m[o]+'</title></rect>'); x+=w; }
947
+ return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12" font-weight="600">'+gEsc(r.p)+'</text>'+segs.join('')+
948
+ '<text x="'+(labW+barW+8)+'" y="'+(y+rowH/2+4)+'" fill="var(--ink-2)" font-size="12" font-variant-numeric="tabular-nums">'+n+(rate==null?'':' · <tspan fill="'+(rate===100?'var(--pass)':r.m.failed+r.m.timedOut+r.m.interrupted?'var(--fail)':'var(--ink-2)')+'" font-weight="600">'+rate+'%</tspan>')+'</text>';
949
+ }).join('');
950
+ const legend=outs.filter(o=>rows.some(r=>r.m[o]>0)).map(o=>'<rect width="10" height="10" fill="'+colors[o]+'"/><text x="14" y="9" fill="var(--ink-2)" font-size="11">'+label[o]+'</text>');
951
+ const legendSvg='<g transform="translate('+labW+','+(H-2)+')">'+legend.map((g,i)=>'<g transform="translate('+(i*90)+',0)">'+g+'</g>').join('')+'</g>';
952
+ return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+(H+18)+'" width="100%">'+parts+legendSvg+'</svg>'});
953
+ }
812
954
  function chartPriorityOutcome(){
813
955
  const order = ['P0','P1','P2','P3','P4'];
814
956
  const outs = ['passed','flaky','failed','timedOut','interrupted','skipped'];
@@ -863,7 +1005,7 @@ function chartDurationHistogram(){
863
1005
  {label:'30s+', min:30000,max:Infinity},
864
1006
  ];
865
1007
  const counts = buckets.map(()=>0);
866
- for(const t of data.tests){ const d=t.duration||0; for(let i=0;i<buckets.length;i++){ if(d>=buckets[i].min && d<buckets[i].max){ counts[i]++; break; } } }
1008
+ for(const t of data.tests){ if(t.outcome==='skipped') continue; const d=t.duration||0; for(let i=0;i<buckets.length;i++){ if(d>=buckets[i].min && d<buckets[i].max){ counts[i]++; break; } } }
867
1009
  const maxN = Math.max(1,...counts);
868
1010
  const W=520, colW=80, gap=16, padL=32, padT=24, padB=36, chartH=180;
869
1011
  const H = chartH+padT+padB;
@@ -878,7 +1020,7 @@ function chartDurationHistogram(){
878
1020
 
879
1021
  /* Chart 4: multi-line — pass rate per priority over the last N history entries */
880
1022
  function chartTrendByPriority(){
881
- const hist = data.history || [];
1023
+ const hist = TREND;
882
1024
  if(hist.length < 2) return null;
883
1025
  const orders = ['P0','P1','P2','P3','P4'];
884
1026
  const seenPri = new Set();
@@ -979,6 +1121,7 @@ function header(){
979
1121
  const pal = h('select',{class:'pal','aria-label':'Palette',onchange:e=>{ html.setAttribute('data-palette',e.target.value); try{localStorage.setItem('rl-palette',e.target.value);}catch(e){} }},
980
1122
  [['lab','Blue'],['ocean','Ocean'],['ember','Ember'],['mono','Mono']].map(([v,l])=>h('option',{value:v,selected:html.getAttribute('data-palette')===v},l)));
981
1123
  const copy = h('button',{class:'btn',onclick:e=>copyText(summaryMarkdown(),e.currentTarget,'Copied for Slack')}, 'Copy summary');
1124
+ const pdf = h('button',{class:'btn',title:'Save the whole report as a PDF — every section, chart, cluster and screenshot, print-friendly',onclick:()=>exportPdf()}, 'Export PDF');
982
1125
  return h('header',{class:'hdr'},
983
1126
  data.options.logo ? h('img',{class:'logo brand',src:data.options.logo,alt:''}) : h('div',{class:'logo',title:'Generated by reportingLabs','aria-label':'reportingLabs'}, document.getElementById('rl-logo').content.cloneNode(true)),
984
1127
  data.options.project ? h('div',{class:'proj-blk'}, h('h1',{}, data.title),
@@ -989,7 +1132,17 @@ function header(){
989
1132
  h('div',{class:'hmeta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))),
990
1133
  h('div',{class:'spacer'}),
991
1134
  h('span',{class:'when'}, new Date(data.startTime).toLocaleString()+' · '+ms(data.duration)+' · '+data.workers+' worker'+(data.workers===1?'':'s')),
992
- h('div',{class:'ctl'}, copy, pal, toggle));
1135
+ h('div',{class:'ctl'}, copy, pdf, pal, toggle));
1136
+ }
1137
+
1138
+ /* ---------- footer: reportingLabs branding ---------- */
1139
+ function rlFooter(){
1140
+ const src=document.getElementById('rl-logo').content.cloneNode(true).querySelector('svg');
1141
+ const svg=src.outerHTML.replace(/(id="|url\(#|href="#)(rl-[a-z]+)/g,'$1$2-f'); // unique ids vs the header's copy
1142
+ return h('footer',{class:'rl-foot'},
1143
+ h('span',{class:'rl-foot-mark',html:svg}),
1144
+ h('span',{class:'rl-foot-txt'}, 'Generated by ', h('b',{},'reportingLabs'),
1145
+ data.generatedAt? ' · '+new Date(data.generatedAt).toLocaleString() : null));
993
1146
  }
994
1147
 
995
1148
  /* ---------- summary ---------- */
@@ -1019,12 +1172,12 @@ function summary(){
1019
1172
  const hasFail=data.tests.some(t=>isFail(t.outcome)||t.outcome==='flaky');
1020
1173
  if(W.attention && hasFail) g.push(h('div',{class:'card w6 fail-rail'}, h('h2',{},'Needs attention', h('span',{class:'hint'},(failed+s.flaky)+' open · ranked by priority and severity')), attention(8)));
1021
1174
  const clusters=failureClusters(); if(clusters.length) g.push(h('div',{class:'card w6 fail-rail'}, h('h2',{},'Failure clusters', h('span',{class:'hint'},clusters.length+' root cause'+(clusters.length>1?'s':'')+' · '+clusters.reduce((a,c)=>a+c.tests.length,0)+' failures')), clustersView(clusters.slice(0,6)), clusters.length>6?h('div',{class:'attn-foot'}, h('button',{onclick:()=>showView('failures')},'View all '+clusters.length+' clusters →')):null));
1022
- if(W.dimensions) g.push(h('div',{class:'card w8'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
1175
+ if(W.dimensions) g.push(h('div',{class:'card w8 bd-card'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
1023
1176
  if(W.slowest){ const reg=regressions().length; g.push(h('div',{class:'card'}, h('h2',{},'Slowest tests', reg?h('span',{class:'hint badge'},reg+' got slower'):null), tabbed([['slow','Slowest',slowest],['reg','Got slower'+(reg?' ('+reg+')':''),slowerView]]))); }
1024
1177
  if(W.flaky!==false && HRUNS.length>1) g.push(h('div',{class:'card'}, h('h2',{},'Flakiest tests', h('span',{class:'hint'},'last '+Math.min(10,HRUNS.length)+' runs')), flakiest()));
1025
1178
  if(W.skipped!==false) { const sc=skippedCard(); if(sc) g.push(sc); }
1026
1179
  if(W.environment!==false) { const ec=envCard(); if(ec) g.push(ec); }
1027
- if(data.history.length>1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'last '+data.history.length+' runs')), trend()));
1180
+ if(data.history.length>1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'last '+TREND.length+' runs'+(SMALL_HIDDEN?' · '+SMALL_HIDDEN+' small run'+(SMALL_HIDDEN>1?'s':'')+' (under '+Math.ceil(MED_SIZE/2)+' tests) not plotted':''))), trend()));
1028
1181
  else if(data.history.length===1) g.push(h('div',{class:'card w12'}, h('h2',{},'Trend', h('span',{class:'hint'},'from the second run')), h('div',{class:'nudge'}, h('b',{},'First run recorded.'),' Run the suite once more and this card shows the pass-rate trend, and every failure gets "new this run" or "failing since #…", last-10-runs dots, a Flakiest tests card and a Got slower tab. History lives in ',h('code',{},data.historyFile||'reporting-labs.history.json'),', commit it or cache it in CI.')));
1029
1182
  const ge=data.globalErrors||[], go=data.globalOutput||[];
1030
1183
  if(ge.length||go.some(x=>x.stream==='err')) g.unshift(h('div',{class:'card w12 fail-rail'}, h('h2',{}, ge.length? ge.length+' error'+(ge.length>1?'s':'')+' outside tests' : 'Output outside tests', h('span',{class:'hint'},'spec files that failed to load, global setup, worker crashes')),
@@ -1038,10 +1191,15 @@ function pill(key,text,n,color,sub,tone,series){
1038
1191
  series? spark(series,color) : null, h('div',{class:'l'},text), h('div',{class:'v'},n), h('div',{class:'d '+(tone||'')},sub||''));
1039
1192
  }
1040
1193
  function initials(name){ return (name||'').split(/[\s\-–—·]+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'R'; }
1194
+ /* The run this one is compared with: the latest earlier run of a comparable size (a full run is not compared
1195
+ with a one-spec debug run, nor a debug run with the full suite); with a build label, a different build. */
1041
1196
  function prevRun(){
1042
1197
  const H=data.history||[]; if(H.length<2) return null;
1043
- const cur=H[H.length-1]; for(let i=H.length-2;i>=0;i--){ if(!cur.label||H[i].label!==cur.label) return H[i]; }
1044
- return H[H.length-2];
1198
+ const cur=H[H.length-1], n=cur.total||0;
1199
+ const comparable=e=> isSmallRun(cur) ? (e.total||0)>=n/2 && (e.total||0)<=n*2 : !isSmallRun(e);
1200
+ let fallback=null;
1201
+ for(let i=H.length-2;i>=0;i--){ if(!comparable(H[i])) continue; if(!cur.label||H[i].label!==cur.label) return H[i]; if(!fallback) fallback=H[i]; }
1202
+ return fallback;
1045
1203
  }
1046
1204
 
1047
1205
  /* ---------- run history per test ---------- */
@@ -1089,7 +1247,7 @@ function slowerView(){
1089
1247
  const rows=regressions().slice(0,6);
1090
1248
  if(!rows.length) return h('div',{class:'ok'},'No test got 2× slower than last run.');
1091
1249
  return h('ul',{class:'slow'}, rows.map(({t,prev,cur,ratio})=>h('li',{}, h('button',{onclick:()=>select(t.id)},
1092
- h('span',{}, h('span',{class:'t'},t.title), h('span',{class:'bar',style:'--w:'+Math.min(100,Math.round(prev/cur*100))+'%'}, h('i',{style:'background:var(--flaky)'}))),
1250
+ h('span',{}, h('span',{class:'t'},t.title, data.projects.length>1?h('small',{class:'proj-sfx'},' · '+t.project):null), h('span',{class:'bar',style:'--w:'+Math.min(100,Math.round(prev/cur*100))+'%'}, h('i',{style:'background:var(--flaky)'}))),
1093
1251
  h('span',{class:'d'}, h('b',{style:'color:var(--fail)'},'×'+(ratio>=10?Math.round(ratio):ratio.toFixed(1))), ' ', ms(prev)+' → '+ms(cur))))));
1094
1252
  }
1095
1253
  /* sparklines from the whole history (counts, no per-test data needed) */
@@ -1100,7 +1258,7 @@ function spark(series, color, w, h2){
1100
1258
  const pts=series.map((v,i)=>x(i)+','+y(v)).join(' ');
1101
1259
  return h('span',{class:'sp','aria-hidden':'true',html:'<svg viewBox="0 0 '+w+' '+h2+'" width="'+w+'" height="'+h2+'"><polyline points="'+pts+'" fill="none" stroke="'+color+'" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" opacity=".9"/><circle cx="'+x(n-1)+'" cy="'+y(series[n-1])+'" r="2.2" fill="'+color+'"/></svg>'});
1102
1260
  }
1103
- function hseries(f){ return HIST.slice(-12).map(f); }
1261
+ function hseries(f){ return TREND.slice(-12).map(f); }
1104
1262
  /* owner rollup for the Failures view and the copied summary */
1105
1263
  function byOwner(){
1106
1264
  const m=new Map();
@@ -1238,13 +1396,28 @@ function donut(){
1238
1396
  const svg=h('div',{html:'<svg viewBox="0 0 132 132">'+arcs+'<text class="c" x="66" y="64" text-anchor="middle">'+rate+'%</text><text class="cl" x="66" y="80" text-anchor="middle">pass rate</text></svg>'}).firstChild;
1239
1397
  return h('div',{class:'donut'}, svg, h('div',{class:'legend'}, parts.map(([k,n])=>h('button',{onclick:()=>{state.status=k;refresh();showView('tests');}}, h('span',{class:'dot',style:'background:'+colorOf(k)}), h('span',{},label[k]), h('span',{class:'n'},n)))));
1240
1398
  }
1399
+ /* Cross-project signal. The same test (file, describe path, title) runs once per project — a browser, a
1400
+ device. Failing in every project it ran in points at the app or the test itself; failing in some projects
1401
+ only points at those browsers. Skipped runs do not count; a flaky pass counts as a pass. */
1402
+ var XP=null; // var: the views are built before this line runs
1403
+ function xpKey(t){ return t.file+'::'+t.path.join('/')+'::'+t.title; }
1404
+ function xproj(t){
1405
+ if(data.projects.length<2) return null;
1406
+ if(!XP){ XP=new Map(); for(const u of data.tests){ if(u.outcome==='skipped') continue; const g=XP.get(xpKey(u))||[]; g.push(u); XP.set(xpKey(u),g); } }
1407
+ const g=XP.get(xpKey(t))||[]; if(g.length<2) return null;
1408
+ const bad=g.filter(u=>isFail(u.outcome)); if(!bad.length) return null;
1409
+ if(bad.length===g.length) return {all:true, text:g.length===2?'fails on both projects':'fails on all '+g.length+' projects', tip:'The same failure in every project it ran in: look at the app or the test, not the browser'};
1410
+ const names=bad.map(u=>u.project), good=g.filter(u=>!isFail(u.outcome)).map(u=>u.project);
1411
+ return {all:false, text: names.length<=2? 'only on '+names.join(' and ') : 'on '+names.length+' of '+g.length+' projects', tip:'Passes on '+good.join(', ')+': specific to '+names.join(', ')};
1412
+ }
1413
+ function xpTag(xp,cls){ return h('span',{class:'xp '+(xp.all?'all':'some')+(cls?' '+cls:''),title:xp.tip},xp.text); }
1241
1414
  function attention(limit){
1242
1415
  const bad=data.tests.filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b)||(isFail(b.outcome)-isFail(a.outcome))||b.duration-a.duration);
1243
1416
  if(!bad.length) return h('div',{class:'ok'},'No failures. Ship it.');
1244
1417
  const groups=new Map(); for(const t of bad){ const k=t.file+'::'+t.path.join('/')+'::'+t.title; const g=groups.get(k)||{t,projects:[]}; g.projects.push(t.project); groups.set(k,g); }
1245
1418
  const all=[...groups.values()], rows=limit?all.slice(0,limit):all;
1246
1419
  const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
1247
- if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
1420
+ if(data.projects.length>1){ const xp=xproj(t); parts.push(xp? xpTag(xp) : projects.length>1? projects.length+' projects' : projects[0]); }
1248
1421
  const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
1249
1422
  const le=t.results[t.results.length-1]&&t.results[t.results.length-1].errors[0]; if(le&&le.explain) parts.push(h('span',{class:'rsn',title:le.explain.summary},le.explain.label));
1250
1423
  else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
@@ -1328,7 +1501,7 @@ function clusterLine(c){
1328
1501
  // in place and "show fewer" folds it again. Each name opens that test.
1329
1502
  function clusterWho(byTitle, all){
1330
1503
  const entries=[...byTitle.entries()], names=all?entries:entries.slice(0,6), extra=entries.length-names.length;
1331
- const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null));
1504
+ const who=names.map(([title,ts])=>h('button',{onclick:()=>select(ts[0].id),title:ts.map(t=>t.project).join(', ')}, h('span',{class:'tri'},'›'), title, data.projects.length>1&&ts.length>1? h('small',{},' ×'+ts.length) : null, (()=>{ const xp=xproj(ts[0]); return xp? h('small',{class:'xp '+(xp.all?'all':'some'),title:xp.tip},' · '+xp.text) : null; })()));
1332
1505
  const swap=(ev,toAll)=>{ const d=ev.currentTarget.closest('.who'); d.replaceChildren(); for(const n of clusterWho(byTitle,toAll)) d.append(n); };
1333
1506
  if(extra>0) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,true),title:'Show all '+entries.length+' tests'},'+'+extra+' more'));
1334
1507
  else if(all&&entries.length>6) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,false)},'show fewer'));
@@ -1342,7 +1515,7 @@ function clustersView(cl){
1342
1515
  }));
1343
1516
  }
1344
1517
  function trend(){
1345
- const H=data.history, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
1518
+ const H=TREND, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
1346
1519
  const x=i=>padL+(n===1?0:i/(n-1))*(W-padL-padR), y=v=>padT+(1-v/100)*(HT-padT-padB);
1347
1520
  const rate=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.passed/ran*100):0; };
1348
1521
  const failPct=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.failed/ran*100):0; };
@@ -1352,9 +1525,14 @@ function trend(){
1352
1525
  svg+='<path class="area" d="M'+x(0)+','+y(0)+' '+H.map((e,i)=>'L'+x(i)+','+y(rate(e))).join(' ')+' L'+x(n-1)+','+y(0)+' Z"/>';
1353
1526
  svg+='<polyline class="fail" points="'+H.map((e,i)=>x(i)+','+y(failPct(e))).join(' ')+'"/>';
1354
1527
  svg+='<polyline class="pass" points="'+H.map((e,i)=>x(i)+','+y(rate(e))).join(' ')+'"/>';
1355
- svg+=H.map((e,i)=>'<circle class="pt'+(i===n-1?' now':'')+'" cx="'+x(i)+'" cy="'+y(rate(e))+'" r="3.5"><title>'+new Date(e.time).toLocaleString()+(e.label?' · '+e.label:'')+' · '+rate(e)+'% pass · '+e.failed+' failed · '+ms(e.duration)+'</title></circle>').join('');
1356
- const step=Math.max(1,Math.ceil(n/8));
1357
- svg+=H.map((e,i)=>(i===n-1||(i%step===0&&n-1-i>=step))?'<text x="'+x(i)+'" y="'+(HT-6)+'" text-anchor="'+(i===n-1&&n>1?'end':'middle')+'">'+(e.label||new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'}))+'</text>':'').join('');
1528
+ svg+=H.map((e,i)=>'<circle class="pt'+(i===n-1&&e===HIST[HIST.length-1]?' now':'')+'" cx="'+x(i)+'" cy="'+y(rate(e))+'" r="3.5"><title>'+new Date(e.time).toLocaleString()+(e.label?' · '+e.label:'')+' · '+rate(e)+'% pass · '+e.failed+' failed · '+ms(e.duration)+'</title></circle>').join('');
1529
+ // Several runs on one day get the time too; longer labels mean fewer of them, so they never overlap
1530
+ const day=e=>new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
1531
+ const days=H.filter(e=>!e.label).map(day), twice=days.length!==new Set(days).size;
1532
+ const step=Math.max(1,Math.ceil(n/(twice?5:8)));
1533
+ const shown=H.map((e,i)=>i===n-1||(i%step===0&&n-1-i>=step));
1534
+ const axis=e=> e.label || (twice ? day(e)+' '+new Date(e.time).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}) : day(e));
1535
+ svg+=H.map((e,i)=>shown[i]?'<text x="'+x(i)+'" y="'+(HT-6)+'" text-anchor="'+(i===n-1&&n>1?'end':i===0&&twice?'start':'middle')+'">'+escape(axis(e))+'</text>':'').join('');
1358
1536
  const colW=n>1?(x(1)-x(0)):(W-padL-padR);
1359
1537
  svg+='<line class="guide" x1="0" x2="0" y1="'+padT+'" y2="'+y(0)+'" style="display:none"/>';
1360
1538
  svg+=H.map((e,i)=>'<rect class="hit" data-i="'+i+'" x="'+(x(i)-colW/2)+'" y="0" width="'+colW+'" height="'+HT+'" fill="transparent"/>').join('');
@@ -1379,7 +1557,7 @@ function trend(){
1379
1557
  svgEl.addEventListener('mousemove',ev=>{ const r=ev.target.closest&&ev.target.closest('.hit'); if(!r){ tip.hidden=true; guide.style.display='none'; return; } show(+r.dataset.i,ev); });
1380
1558
  svgEl.addEventListener('mouseleave',()=>{ tip.hidden=true; guide.style.display='none'; });
1381
1559
  wrap.append(
1382
- h('div',{class:'trend-cap'}, h('span',{style:'color:var(--pass)'},'— pass rate'), h('span',{style:'color:var(--fail)'},'- - fail rate'), h('span',{style:'color:var(--accent)'},'▮ duration'), h('span',{style:'margin-left:auto'}, 'this run: '+rate(H[n-1])+'% · '+ms(H[n-1].duration))));
1560
+ h('div',{class:'trend-cap'}, h('span',{style:'color:var(--pass)'},'— pass rate'), h('span',{style:'color:var(--fail)'},'- - fail rate'), h('span',{style:'color:var(--accent)'},'▮ duration'), h('span',{style:'margin-left:auto'}, 'this run: '+rate(HIST[HIST.length-1])+'% · '+ms(HIST[HIST.length-1].duration)+(isSmallRun(HIST[HIST.length-1])?' · small run, not plotted':''))));
1383
1561
  return wrap;
1384
1562
  }
1385
1563
  function workers(){
@@ -1418,7 +1596,7 @@ function slowest(){
1418
1596
  const top=[...data.tests].filter(t=>t.outcome!=='skipped').sort((a,b)=>b.duration-a.duration).slice(0,5);
1419
1597
  const max=top[0]?top[0].duration:1;
1420
1598
  return h('ul',{class:'slow'}, top.map(t=>h('li',{}, h('button',{onclick:()=>select(t.id)},
1421
- h('span',{}, h('span',{class:'t'},t.title), h('span',{class:'bar',style:'--w:'+Math.max(4,Math.round(t.duration/max*100))+'%'}, h('i',{}))), h('span',{class:'d'},ms(t.duration))))));
1599
+ h('span',{}, h('span',{class:'t'},t.title, data.projects.length>1?h('small',{class:'proj-sfx'},' · '+t.project):null), h('span',{class:'bar',style:'--w:'+Math.max(4,Math.round(t.duration/max*100))+'%'}, h('i',{}))), h('span',{class:'d'},ms(t.duration))))));
1422
1600
  }
1423
1601
  function projects(){
1424
1602
  return h('div',{class:'proj'}, data.projects.map(p=>{
@@ -1514,30 +1692,31 @@ function select(id, quiet){
1514
1692
  state.selected=id; state.retry=t.results.length-1; if(!quiet){ if(state.view!=='tests') showView('tests'); else setHash(); } refresh(); renderDetail(t);
1515
1693
  const it=document.querySelector('.item[data-id="'+id+'"]'); if(it&&it.scrollIntoView) it.scrollIntoView({block:'nearest'});
1516
1694
  }
1517
- function renderDetail(t){
1518
- const d=$('#detail'); d.innerHTML='';
1695
+ function renderDetail(t, d, retryIdx){
1696
+ d=d||$('#detail'); d.innerHTML='';
1697
+ const retry = retryIdx==null ? state.retry : retryIdx;
1519
1698
  d.append(h('div',{class:'crumb'}, [t.file, ...t.path.map(p=>data.bdd&&!/^Feature:/i.test(p)?'Feature: '+p:p)].map(p=>h('span',{},p))),
1520
1699
  h('div',{class:'titlebar'}, h('h3',{},t.title), h('div',{class:'actions'},
1521
1700
  h('button',{class:'btn',onclick:e=>copyText(location.href.split('#')[0]+'#t='+t.id,e.currentTarget,'Link copied')},'Copy link'),
1522
1701
  (()=>{ const r=t.results[t.results.length-1]; const e=r&&r.errors[0]; return e? [h('button',{class:'btn',onclick:ev=>copyText(t.title+'\n'+t.file+':'+t.line+'\n\n'+e.message,ev.currentTarget,'Error copied')},'Copy error'), h('button',{class:'btn primary',onclick:()=>openBugReport(t)},'Bug report')] : null; })(),
1523
1702
  editorLink(t))),
1524
- h('div',{class:'badges'}, h('span',{class:'badge '+t.outcome},label[t.outcome]), t.expectedFailure? h('span',{class:'badge xfail',title:'Marked test.fail(): failing is the expected result'},'Expected failure') : null, sinceBadge(t), dots(t,10), data.bdd?h('span',{class:'badge scenario'},'Scenario'):null, t.tags.map(g=>h('span',{class:'badge tag'},g)), data.projects.length>1?h('span',{class:'badge tag'},t.project):null, h('span',{class:'loc'}, t.file+':'+t.line+' · '+ms(t.duration)+(t.outcome==='timedOut'&&t.timeout?' · timeout '+ms(t.timeout):'')+(t.retries?' · retries '+t.retries:''))));
1703
+ h('div',{class:'badges'}, h('span',{class:'badge '+t.outcome},label[t.outcome]), t.expectedFailure? h('span',{class:'badge xfail',title:'Marked test.fail(): failing is the expected result'},'Expected failure') : null, sinceBadge(t), dots(t,10), data.bdd?h('span',{class:'badge scenario'},'Scenario'):null, t.tags.map(g=>h('span',{class:'badge tag'},g)), data.projects.length>1?h('span',{class:'badge tag'},t.project):null, (()=>{ const xp=isFail(t.outcome)&&xproj(t); return xp? xpTag(xp,'badge') : null; })(), h('span',{class:'loc'}, t.file+':'+t.line+' · '+ms(t.duration)+(t.outcome==='timedOut'&&t.timeout?' · timeout '+ms(t.timeout):'')+(t.retries?' · retries '+t.retries:''))));
1525
1704
  const metaKeys=Object.keys(t.meta);
1526
1705
  const linkFor=(k,v)=>{ if(t.links&&t.links[k]) return t.links[k]; const tpl=data.options.links[k]||data.options.links['*']; if(tpl) return tpl.replace('{id}',encodeURIComponent(v)); if(/^https?:\/\//.test(v)) return v; return null; };
1527
1706
  if(metaKeys.length) d.append(h('div',{class:'metas'}, metaKeys.map(k=>{ const v=t.meta[k], low=/^(P[3-4]|low|minor|trivial|normal|medium)$/i.test(v); const parts=(t.links&&t.links[k])||!data.options.links[k]&&!data.options.links['*']?[v]:v.split(/,\s+/); const nodes=[]; parts.forEach((part,i)=>{ const href=linkFor(k,part); if(i) nodes.push(', '); nodes.push(href? h('a',{href,target:'_blank',rel:'noopener'},part) : h('span',{class:'v'},part)); }); return h('span',{class:'meta '+k+(low?' low':'')}, h('span',{class:'k'},k), ...nodes); })));
1528
1707
  const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
1529
1708
  if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
1530
1709
  if(t.results.length>1){
1531
- d.append(h('div',{class:'tabs'}, t.results.map((r,i)=>h('button',{class:'tab','aria-selected':state.retry===i,onclick:()=>{state.retry=i;renderDetail(t);}}, (i===0?'Attempt 1':'Retry '+i)+' · '+(label[r.status]||r.status)))));
1710
+ d.append(h('div',{class:'tabs'}, t.results.map((r,i)=>h('button',{class:'tab','aria-selected':retry===i,onclick:()=>{state.retry=i;renderDetail(t);}}, (i===0?'Attempt 1':'Retry '+i)+' · '+(label[r.status]||r.status)))));
1532
1711
  }
1533
- const r=t.results[state.retry]; if(!r){ d.append(h('p',{class:'empty'},'This test did not run.')); return; }
1712
+ const r=t.results[retry]; if(!r){ d.append(h('p',{class:'empty'},'This test did not run.')); return; }
1534
1713
  const body=h('div',{});
1535
- if(t.note&&state.retry===t.results.length-1) body.append(h('div',{class:'note '+(t.outcome==='passed'?'ok':'warn')}, t.note));
1714
+ if(t.note&&retry===t.results.length-1) body.append(h('div',{class:'note '+(t.outcome==='passed'?'ok':'warn')}, t.note));
1536
1715
  if(r.errors.length){ body.append(h('h4',{},r.errors.length>1?'Errors':'Error'), ...r.errors.map(errorView)); }
1537
1716
  if(r.steps.length){ const stepTotal=Math.max(1, r.duration||0, r.steps.reduce((a,x)=>a+x.duration,0)); body.append(h('h4',{},data.bdd?'Scenario steps':'Steps', h('span',{class:'hint'},'bar = share of '+ms(stepTotal))), stepTree(r.steps, stepTotal)); }
1538
1717
  if(r.logs.length){ const t0=r.startTime; body.append(h('h4',{},'Log'), h('div',{class:'logs'}, r.logs.map(l=>h('div',{class:'ln'+(/\b(error|fail|exception)\b/i.test(l.msg)?' err':/\bwarn/i.test(l.msg)?' warn':'')}, h('span',{class:'ts'},'+'+ms(Math.max(0,l.t-t0))), h('span',{class:'lm'},l.msg))))); }
1539
1718
  for(const b of r.data){ body.append(h('h4',{},b.name), dataBlock(b)); }
1540
- if(r.api.length){ body.append(h('h4',{},r.api.length+' API call'+(r.api.length>1?'s':'')), ...r.api.map(apiPanel)); body.append(h('div',{class:'mask-note'},'Secrets and auth headers are masked as ****')); }
1719
+ if(r.api.length){ body.append(h('h4',{},r.api.length+' API call'+(r.api.length>1?'s':'')), ...r.api.map(c=>apiPanel(c,r))); body.append(h('div',{class:'mask-note'},'Secrets and auth headers are masked as ****')); }
1541
1720
  const imgs=r.attachments.filter(a=>a.src&&a.contentType.startsWith('image/'));
1542
1721
  const vids=r.attachments.filter(a=>a.src&&a.contentType.startsWith('video/'));
1543
1722
  const traces=r.attachments.filter(a=>a.src&&(a.name==='trace'||/\.zip$/.test(a.src)));
@@ -1582,10 +1761,10 @@ function dataBlock(b){
1582
1761
  if(b.kind==='kv') return h('dl',{class:'kv2'}, b.kv.map(([k,v])=>[h('dt',{},k),h('dd',{class:v==='****'?'mask':''},v)]));
1583
1762
  return h('pre',{class:'txt'},b.text);
1584
1763
  }
1585
- function apiPanel(c){
1586
- const st=c.status||0, cls=st>=500||st>=400?'bad':st>=300?'warn':'ok';
1764
+ function apiPanel(c,r){
1765
+ const st=c.status||0, cls=apiCls(c,r);
1587
1766
  const wrap=h('div',{class:'api collapsed'});
1588
- const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls},st):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
1767
+ const head=h('button',{class:'api-head',onclick:()=>wrap.classList.toggle('collapsed')}, h('span',{class:'m '+c.method.toUpperCase()},c.method.toUpperCase()), h('span',{class:'u',title:c.url},c.url), st?h('span',{class:'sc '+cls,title:cls==='exp'?'The test passed: this is the response it expected':''},st):null, cls==='exp'?h('span',{class:'sc-exp'},'expected'):null, c.duration!=null?h('span',{class:'d'},ms(c.duration)):null);
1589
1768
  const pre=v=>v==null?null:h('pre',{}, typeof v==='string'?v:JSON.stringify(v,null,2));
1590
1769
  const col=(title,headers,bodyv)=>h('div',{class:'api-col'}, h('h5',{},title), headers&&Object.keys(headers).length? [h('h5',{},'Headers'), pre(headers)] : null, bodyv!=null? [h('h5',{},'Body'), pre(bodyv)] : h('div',{style:'font-size:12px;color:var(--ink-3)'},'no body'));
1591
1770
  const tools=h('div',{class:'api-tools'}, h('button',{class:'btn',onclick:e=>copyText(curlOf(c),e.currentTarget,'Copied')},'Copy as cURL'), h('button',{class:'btn',onclick:e=>copyText(c.url,e.currentTarget,'Copied')},'Copy URL'));
@@ -1688,6 +1867,125 @@ function openBugReport(t){
1688
1867
  }
1689
1868
  function lightbox(src){ const lb=h('div',{class:'lb',onclick:()=>lb.remove()}, h('img',{src})); document.body.append(lb); }
1690
1869
  function escape(s){ return s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1870
+
1871
+ /* ---------- print / PDF ----------
1872
+ One builder serves both the "Export PDF" button (window.print) and headless page.pdf():
1873
+ Chromium fires 'beforeprint' for both. We assemble the whole report — cover, overview,
1874
+ failures, graphs, API and every test's detail expanded — into a .printdoc, force the light
1875
+ theme for paper, print, then tear it all down so the live app is untouched. Video is skipped. */
1876
+ function buildPrintDoc(){
1877
+ if(document.querySelector('.printdoc')) return;
1878
+ const html=document.documentElement;
1879
+ html.setAttribute('data-rl-theme-prev', html.getAttribute('data-theme')||'');
1880
+ html.setAttribute('data-theme','light'); // always light on paper, whatever the screen theme
1881
+ const pd=h('div',{class:'printdoc'});
1882
+ const s=data.stats, f=s.failed+s.timedOut+s.interrupted, ran=s.total-s.skipped, rate=ran?Math.round(s.passed/ran*100):0;
1883
+ const stat=(n,l,c)=>h('div',{class:'pstat'}, h('div',{class:'n',style:c?'color:'+c:''},String(n)), h('div',{class:'l'},l));
1884
+ let logo;
1885
+ if(data.options.logo){ logo = h('img',{src:data.options.logo,alt:'',style:'width:40px;height:40px;border-radius:8px'}); }
1886
+ else {
1887
+ // The live header already holds one copy of this SVG; in print that copy is display:none, and its
1888
+ // gradient/clip ids would win over an identical clone (first id in the document). Rewrite the ids so
1889
+ // the cover's logo paints its gradients and flask clip correctly.
1890
+ const src=document.getElementById('rl-logo').content.cloneNode(true).querySelector('svg');
1891
+ const html2=src.outerHTML.replace(/(id="|url\(#|href="#)(rl-[a-z]+)/g,'$1$2-p');
1892
+ const holder=h('span',{class:'logo',style:'display:inline-flex;width:40px;height:40px',html:html2});
1893
+ logo=holder;
1894
+ }
1895
+ const verdict = f ? (f+' of '+s.total+' test'+(s.total===1?'':'s')+' failed'+(s.flaky?', '+s.flaky+' flaky':''))
1896
+ : (ran===0 ? 'No tests ran' : 'All '+ran+' test'+(ran===1?'':'s')+' passed'+(s.flaky?', '+s.flaky+' flaky':'')+(s.skipped?' · '+s.skipped+' skipped':''));
1897
+ // ---- page 1: executive summary ----
1898
+ pd.append(h('div',{class:'pcover'},
1899
+ h('div',{class:'pcover-top'}, logo,
1900
+ h('div',{}, h('div',{class:'pcover-title'}, data.title),
1901
+ h('div',{class:'pcover-sub'}, (data.options.project?[data.options.project.name, data.options.project.version?'v'+data.options.project.version:null, data.options.project.team].filter(Boolean).join(' · ')+' · ':'')
1902
+ + new Date(data.startTime).toLocaleString()+' · '+ms(data.duration)+' · '+data.workers+' worker'+(data.workers===1?'':'s')))),
1903
+ Object.keys(data.metadata).length? h('div',{class:'pcover-meta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))) : null,
1904
+ h('div',{class:'pverdict '+(f?'bad':'ok')}, verdict),
1905
+ h('div',{class:'pcover-stats'},
1906
+ stat(rate+'%','Pass rate'), stat(s.passed,'Passed','var(--pass)'),
1907
+ f?stat(f,'Failed','var(--fail)'):null, s.flaky?stat(s.flaky,'Flaky','var(--flaky)'):null,
1908
+ s.skipped?stat(s.skipped,'Skipped','var(--ink-3)'):null, stat(s.total,'Total'))));
1909
+ const pexec=h('div',{class:'pexec'});
1910
+ pexec.append(h('div',{class:'pexec-card'}, h('div',{class:'psub-h'},'Pass rate'), donut()));
1911
+ const envc=printEnvCard(); if(envc) pexec.append(envc);
1912
+ pd.append(pexec);
1913
+
1914
+ // The first section flows right after the executive summary so page 1 is never half-empty;
1915
+ // the rest start on a fresh page.
1916
+ let first=true;
1917
+ const sec=(title,node)=>{ if(!node) return; pd.append(h('section',{class:'psec'+(first?'':' brk')}, h('h2',{class:'psec-h'},title), node)); first=false; };
1918
+ // ---- analytics, failure analysis, full inventory, evidence ----
1919
+ if(data.tests.length) sec('Test analytics', graphsView());
1920
+ if(f + s.flaky > 0){
1921
+ const fn=failuresView();
1922
+ // On paper there is no "+N more" to click, so expand every cluster to list all its tests.
1923
+ fn.querySelectorAll('.clu2 .who .ex').forEach(b=>{ try{ b.click(); }catch(e){} });
1924
+ sec('Failure analysis', fn);
1925
+ }
1926
+ sec('All test cases', allTestsTable());
1927
+ const bad=[...data.tests].filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b)||a.title.localeCompare(b.title));
1928
+ if(bad.length){ const ev=h('div',{}); for(const t of bad) ev.append(printEvidence(t)); sec('Failure evidence', ev); }
1929
+ pd.append(h('div',{class:'pfoot'}, 'Generated by reportingLabs · '+new Date().toLocaleString()));
1930
+ document.body.append(pd);
1931
+ }
1932
+ function printEnvCard(){
1933
+ if(!data.env||!data.env.length) return null;
1934
+ return h('div',{class:'pexec-card'}, h('div',{class:'psub-h'},'Environment'),
1935
+ h('dl',{class:'kv env'}, data.env.flatMap(r=>[h('dt',{},r.k), h('dd',{}, r.href? h('a',{href:r.href},r.v) : r.v)])));
1936
+ }
1937
+ function allTestsTable(){
1938
+ const hasPri=data.tests.some(t=>t.meta.priority||t.meta.severity), hasOwn=data.tests.some(t=>t.meta.owner), multiProj=data.projects.length>1;
1939
+ const cols=[['c-n','#'],['','Test'],['','Spec'], hasPri?['c-pri','Priority']:null, hasOwn?['c-own','Owner']:null, ['c-st','Status'], ['c-dur','Duration']].filter(Boolean);
1940
+ const order=[...data.tests].sort((a,b)=>((isFail(b.outcome)||b.outcome==='flaky')-(isFail(a.outcome)||a.outcome==='flaky'))||rank(a)-rank(b)||a.file.localeCompare(b.file)||a.title.localeCompare(b.title));
1941
+ let i=0;
1942
+ return h('table',{class:'ptbl'},
1943
+ h('colgroup',{}, cols.map(([c])=>h('col',{class:c||''}))),
1944
+ h('thead',{}, h('tr',{}, cols.map(([,l])=>h('th',{},l)))),
1945
+ h('tbody',{}, order.map(t=>{ i++; return h('tr',{}, [
1946
+ h('td',{class:'num'}, String(i)),
1947
+ h('td',{}, h('div',{class:'pt-name'}, t.title), t.path.length? h('div',{class:'pt-sub'}, t.path.join(' › ')) : null),
1948
+ h('td',{}, h('span',{class:'pt-spec'}, t.file.split('/').pop()+(multiProj?' · '+t.project:''))),
1949
+ hasPri? h('td',{}, [t.meta.priority,t.meta.severity].filter(Boolean).join(' · ')) : null,
1950
+ hasOwn? h('td',{}, t.meta.owner||'') : null,
1951
+ h('td',{}, h('span',{class:'pstatus'}, h('span',{class:'st '+t.outcome,style:'background:'+colorOf(bucket(t))}), label[t.outcome])),
1952
+ h('td',{class:'num'}, ms(t.duration)),
1953
+ ].filter(x=>x!==null)); })));
1954
+ }
1955
+ function printEvidence(t){
1956
+ const r=t.results[t.results.length-1], e=r&&r.errors[0];
1957
+ const art=h('article',{class:'pev'});
1958
+ art.append(h('div',{class:'pev-head'}, h('span',{class:'st',style:'background:'+colorOf(bucket(t))}), h('span',{class:'pev-title'}, t.title), h('span',{class:'badge '+t.outcome}, label[t.outcome])));
1959
+ art.append(h('div',{class:'pev-loc'}, t.file+':'+t.line+(data.projects.length>1?' · '+t.project:'')+' · '+ms(t.duration)+(t.results.length>1?' · '+t.results.length+' attempts':'')+(t.meta.owner?' · '+t.meta.owner:'')+(t.meta.priority?' · '+t.meta.priority:'')));
1960
+ if(e){ if(e.explain) art.append(whyView(e.explain)); art.append(errorBody(e)); }
1961
+ else art.append(h('div',{style:'color:var(--ink-3)'}, t.outcome==='flaky'?'Passed on retry after an earlier failure.':'No error message was recorded.'));
1962
+ const imgs=(r?r.attachments:[]).filter(a=>a.src&&(a.contentType||'').startsWith('image/')&&!/-(expected|actual|diff)(\.\w+)?$/.test(a.name));
1963
+ if(imgs.length) art.append(h('div',{class:'att'}, imgs.slice(0,2).map(a=>h('figure',{}, h('img',{src:a.src,alt:a.name}), h('figcaption',{},a.name)))));
1964
+ return art;
1965
+ }
1966
+ function teardownPrintDoc(){
1967
+ const pd=document.querySelector('.printdoc'); if(pd) pd.remove();
1968
+ const html=document.documentElement, prev=html.getAttribute('data-rl-theme-prev');
1969
+ if(prev!=null){ if(prev) html.setAttribute('data-theme',prev); else html.removeAttribute('data-theme'); html.removeAttribute('data-rl-theme-prev'); }
1970
+ }
1971
+ // Build the print document and wait for every screenshot (a data: URI) to actually decode, so it
1972
+ // is painted — otherwise a print fired right after the DOM is built captures blank image boxes.
1973
+ async function preparePrint(){
1974
+ buildPrintDoc();
1975
+ try{ await Promise.all([...document.querySelectorAll('.printdoc img')].map(im=> im.decode? im.decode().catch(()=>{}) : Promise.resolve())); }catch(e){}
1976
+ return document.querySelectorAll('.printdoc img').length;
1977
+ }
1978
+ async function exportPdf(){
1979
+ await preparePrint();
1980
+ try{ window.print(); }catch(e){}
1981
+ }
1982
+ // A headless driver (the reporter's auto-generated report.pdf) awaits this, then calls page.pdf().
1983
+ window.reportingLabsPreparePrint = preparePrint;
1984
+ // A plain headless Chrome launched with --print-to-pdf on this file with ?rl-print (how the Java/CLI
1985
+ // generator renders report.pdf) has no way to call the hook, so build the print document on load instead.
1986
+ if(/[?&#]rl-print\b/.test(location.href)){ if(document.readyState!=='loading') preparePrint(); else window.addEventListener('DOMContentLoaded', preparePrint); }
1987
+ window.addEventListener('beforeprint', buildPrintDoc); // native Ctrl+P: build if not already built
1988
+ window.addEventListener('afterprint', teardownPrintDoc);
1691
1989
  })();
1692
1990
  </script>
1693
1991
  </body>