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.
package/dist/template.js CHANGED
@@ -415,6 +415,10 @@ li.collapsed>ul{display:none}
415
415
  .wk .row{display:grid;grid-template-columns:36px 1fr 120px;gap:10px;align-items:center;font-size:12px}
416
416
  .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%}
417
417
  .wk .n{font:11px var(--mono);color:var(--ink-3);text-align:right}
418
+ .wk .row{grid-template-columns:36px 1fr auto} .wk .n{white-space:nowrap}
419
+ .proj-sfx{font-weight:400;color:var(--ink-3);font-size:.9em}
420
+ /* the Breakdown card keeps its own height: a two-priority suite should not leave a tall empty card */
421
+ .bd-card{align-self:start}
418
422
  .wk-sum{font-size:13px;color:var(--ink-2);margin-bottom:10px}
419
423
  /* list: group toggle + folder tree */
420
424
  .tools .seg,.bug-tools .seg{display:inline-flex;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}
@@ -450,7 +454,9 @@ table.tbl{border-collapse:collapse;font-size:12.5px;width:100%;min-width:100%}
450
454
  .api-head .m{font:11px/1 var(--mono);font-weight:600;padding:4px 7px;border-radius:3px;color:#fff;background:var(--ink-3)}
451
455
  .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)}
452
456
  .api-head .u{flex:1;font:12px var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
453
- .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}
457
+ .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)}
458
+ .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}
459
+ .api-stats .exp b{color:var(--ink-2)} .api-proj{color:var(--ink-3)}
454
460
  .api-head .d{font:11px var(--mono);color:var(--ink-3)}
455
461
  .api-body{display:grid;grid-template-columns:1fr 1fr;gap:0}
456
462
  @media (max-width:900px){.api-body{grid-template-columns:1fr}}
@@ -630,13 +636,15 @@ html,body{-webkit-font-smoothing:antialiased}
630
636
  .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)}
631
637
  .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)}
632
638
  .badge.xfail{background:var(--flaky-bg);color:var(--flaky-ink)}
639
+ .xp{font-weight:500} .xp.all{color:var(--ink)} .xp.some{color:var(--flaky-ink)}
640
+ .badge.xp{background:var(--surface-2);border:1px solid var(--line)} .badge.xp.some{background:var(--flaky-bg);border-color:transparent}
633
641
  .errwrap+.errwrap{margin-top:14px}
634
642
  .why{border:1px solid var(--line);border-radius:var(--radius);padding:10px 14px;margin-bottom:10px;background:var(--surface)}
635
643
  .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}
636
644
  .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)}
637
645
  .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}
638
646
  .why.assertion .why-k,.why.visual .why-k{color:#9A6A00}
639
- .why.script .why-k,.why.thrown .why-k,.why.file .why-k{color:var(--ink-2)}
647
+ .why.script .why-k,.why.thrown .why-k,.why.file .why-k,.why.crashed .why-k{color:var(--ink-2)}
640
648
  .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}
641
649
  .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)}
642
650
  /* ---------- triage extras: history, diff, export, env, heatmap ---------- */
@@ -718,6 +726,104 @@ html,body{font-size:var(--fs)}
718
726
  .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)}
719
727
  .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)}
720
728
  .chip{font-family:var(--sans)} .chip b{font-weight:500}
729
+
730
+ /* ---------- report footer: reportingLabs branding ---------- */
731
+ .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}
732
+ .rl-foot-mark{width:22px;height:22px;display:inline-flex;flex:none}
733
+ .rl-foot-mark svg{width:22px;height:22px;display:block;border-radius:6px}
734
+ .rl-foot-txt b{color:var(--ink-2);font-weight:600}
735
+ @media print{ .rl-foot{display:none} }
736
+
737
+ /* ---------- print / PDF: a clean, client-shareable executive report ----------
738
+ Page 1 is a full executive summary (no wasted space); analytics, failure analysis, the
739
+ full test-case table and failure evidence each start on a fresh page. Per-test steps,
740
+ logs, API and console are left out so the PDF stays small and readable at 500+ tests. */
741
+ .printdoc{display:none}
742
+ @media print{
743
+ @page{ size:A4; margin:12mm 11mm 13mm; }
744
+ html,body{background:#fff!important}
745
+ #app{display:none!important}
746
+ .printdoc{display:block!important;max-width:none;margin:0;padding:0;color:var(--ink);font-size:12px}
747
+ .printdoc *{ -webkit-print-color-adjust:exact!important; print-color-adjust:exact!important; }
748
+ /* On paper nothing can be clicked or hovered, so never truncate text with an ellipsis or a
749
+ height clamp — wrap it and show it in full. Numbers, durations and status chips stay on one line. */
750
+ .printdoc .apitbl .u,.printdoc .apitbl .t,.printdoc .apitbl td,.printdoc .slow .t,.printdoc .proj .name,
751
+ .printdoc .file,.printdoc .item .tt .p,.printdoc .item .tt .n,.printdoc .item .d,.printdoc .step .t,
752
+ .printdoc .att figcaption,.printdoc .dim .k,.printdoc .attn .t,.printdoc .attn .t .tt,.printdoc .attn .sev,
753
+ .printdoc .attn2 .pr,.printdoc .attn2 .ow,.printdoc .attn2 .tt .n,.printdoc .attn2 .tt .s,
754
+ .printdoc .tbl td,.printdoc .tbl th,.printdoc .api-head .u,.printdoc .clu .msg,.printdoc .clu .n,
755
+ .printdoc .clu2 .msg,.printdoc .clu2 .who,.printdoc .clu2 .who button,.printdoc .own .ts,.printdoc .own .nm,
756
+ .printdoc .hist .b .l,.printdoc .loc,.printdoc .crumb span,.printdoc .pt-name,.printdoc .pt-sub,.printdoc .pt-spec{
757
+ white-space:normal!important;overflow:visible!important;text-overflow:clip!important;
758
+ max-width:none!important;max-height:none!important;overflow-wrap:break-word;-webkit-line-clamp:none!important;
759
+ }
760
+ /* durations / counts / status stay on one line so "9ms" never breaks to "9m s" */
761
+ .printdoc .apitbl td.t:last-child,.printdoc .num,.printdoc .pstatus,.printdoc .since-txt{white-space:nowrap!important;overflow-wrap:normal!important}
762
+ /* only genuinely unbreakable strings (urls, file paths, selectors) may break mid-token */
763
+ .printdoc .apitbl .u,.printdoc .api-head .u,.printdoc .pt-spec,.printdoc .loc,.printdoc .file,.printdoc .clu .msg,.printdoc .clu2 .msg{overflow-wrap:anywhere}
764
+ /* Content flows continuously — no forced page breaks between sections, which were leaving
765
+ whole pages nearly empty. Cards/rows just avoid being split across a page. */
766
+ .printdoc .psec{padding-top:0;margin-top:18px}
767
+ .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}
768
+ /* Only small, atomic blocks avoid a page split. Big cards (clusters, needs-attention, the tables)
769
+ may flow across pages — keeping them whole is what left pages half-empty. */
770
+ .printdoc .gchart,.printdoc figure,.printdoc .kpi,.printdoc .hm,.printdoc .pev,.printdoc .pexec-card,.printdoc tr{break-inside:avoid}
771
+ .printdoc h4{break-after:avoid}
772
+ /* CSS grid does not fragment across print pages, so a tall two-column grid jumps to the next page
773
+ and leaves a gap. In print everything becomes a single full-width column that flows naturally. */
774
+ .printdoc .grid{display:block!important;margin:0}
775
+ .printdoc .grid>*{grid-column:auto!important;width:auto!important;max-width:none!important;margin:0 0 12px!important;break-inside:avoid}
776
+ .printdoc .grid>.card.w12,.printdoc .grid>.fail-rail,.printdoc .grid>.fx-tools{break-inside:auto}
777
+ .printdoc .graphs-grid>*{break-inside:avoid}
778
+ .printdoc .list,.printdoc .nav,.printdoc .band,.printdoc .stripe,.printdoc .fx-tools .btn,.printdoc .fx-tools .spacer{display:none!important}
779
+ /* drop interactive-only chrome and anything that cannot live on paper */
780
+ .printdoc .actions,.printdoc .gchart-head button,.printdoc .vids,.printdoc .cmp,.printdoc .hint-kbd,.printdoc .mask-note,.printdoc .clu2 .who .ex{display:none!important}
781
+ .printdoc a{color:var(--ink);text-decoration:none}
782
+ /* ---- cover / executive summary (page 1) ---- */
783
+ .printdoc .pcover{padding:2mm 0 0}
784
+ .printdoc .pcover-top{display:flex;align-items:center;gap:13px}
785
+ .printdoc .pcover .logo{width:44px;height:44px;flex:none}
786
+ .printdoc .pcover .logo svg{width:44px;height:44px;display:block;border-radius:11px}
787
+ .printdoc .pcover-title{font-size:25px;font-weight:600;letter-spacing:-.02em;line-height:1.1}
788
+ .printdoc .pcover-sub{color:var(--ink-3);font-size:12px;margin-top:3px}
789
+ .printdoc .pcover-meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
790
+ .printdoc .pverdict{font-size:17px;font-weight:600;margin:16px 0 2px}
791
+ .printdoc .pverdict.ok{color:var(--pass)} .printdoc .pverdict.bad{color:var(--fail)}
792
+ .printdoc .pcover-stats{display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 4px}
793
+ .printdoc .pstat{border:1px solid var(--line);border-radius:10px;padding:9px 15px;min-width:84px;background:var(--surface)}
794
+ .printdoc .pstat .n{font-size:25px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.1}
795
+ .printdoc .pstat .l{font-size:10px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.05em;margin-top:3px}
796
+ .printdoc .pexec{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:16px;align-items:start}
797
+ .printdoc .pexec-card{border:1px solid var(--line);border-radius:12px;padding:14px 16px;background:var(--surface)}
798
+ .printdoc .psub-h{font-size:12.5px;font-weight:600;color:var(--ink);margin:0 0 10px}
799
+ /* ---- the all-tests table ---- */
800
+ .printdoc .ptbl{width:100%;border-collapse:collapse;font-size:11px;table-layout:fixed}
801
+ .printdoc .ptbl thead{display:table-header-group}
802
+ .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)}
803
+ .printdoc .ptbl td{padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}
804
+ .printdoc .ptbl td.num{text-align:right;font-variant-numeric:tabular-nums;color:var(--ink-2);white-space:nowrap}
805
+ .printdoc .ptbl .pt-name{font-weight:500;color:var(--ink)}
806
+ .printdoc .ptbl .pt-sub{font-size:10px;color:var(--ink-3);margin-top:1px}
807
+ .printdoc .ptbl .pt-spec{font:10.5px var(--mono);color:var(--ink-2)}
808
+ .printdoc .pstatus{display:inline-flex;align-items:center;gap:6px;font-weight:500;white-space:nowrap}
809
+ .printdoc .pstatus .st{width:9px;height:9px;border-radius:50%;flex:none}
810
+ .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}
811
+ /* ---- failure evidence ---- */
812
+ .printdoc .pev{padding:14px 0;border-top:1px solid var(--line)}
813
+ .printdoc .pev:first-child{border-top:0;padding-top:2px}
814
+ .printdoc .pev-head{display:flex;align-items:center;gap:9px}
815
+ .printdoc .pev-head .st{width:10px;height:10px;border-radius:50%;flex:none}
816
+ .printdoc .pev-title{font-size:14px;font-weight:600;letter-spacing:-.01em}
817
+ .printdoc .pev-loc{font:11px var(--mono);color:var(--ink-3);margin:3px 0 8px}
818
+ .printdoc .pev .why{margin-bottom:8px}
819
+ .printdoc .pev .att{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px}
820
+ .printdoc .pev .att figure{flex:1 1 320px;min-width:0;margin:0}
821
+ .printdoc .pev .att img{width:100%;max-height:115mm;object-fit:contain;border:1px solid var(--line);border-radius:6px;background:#fff}
822
+ .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}
823
+ .printdoc .pev .errfull,.printdoc .pev details{display:none!important}
824
+ .printdoc .pfoot{margin-top:5mm;padding-top:3mm;border-top:1px solid var(--line);font-size:10px;color:var(--ink-3)}
825
+ .printdoc .err,.printdoc pre{white-space:pre-wrap;word-break:break-word}
826
+ }
721
827
  `;
722
828
  const JS = String.raw `
723
829
  (function(){
@@ -733,6 +839,14 @@ const HIST = data.history||[];
733
839
  const HRUNS = HIST.filter(e=>e&&e.tests); // entries that carry per-test outcomes; the last one is this run
734
840
  const PREV = HRUNS.length>1 ? HRUNS.slice(0,-1) : []; // earlier runs only
735
841
  const runLabel = e => e.label || new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
842
+ /* Run-level trends (Trend card, KPI sparklines and "from last run", pass rate by priority) only use full-size
843
+ runs. A debug run of one spec or a --grep shares the history file with the full suite; a failing 1-test run
844
+ would read as a 0% day. A run is "small" when it ran fewer than half the median size of the recorded runs.
845
+ Per-test history (dots, flaky, new vs known) still uses every run: there a run only counts for the tests in it. */
846
+ 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; })();
847
+ const isSmallRun = e => HIST.length>=3 && (e.total||0) < MED_SIZE/2;
848
+ const TREND = (()=>{ const f=HIST.filter((e,i)=>!isSmallRun(e)); return f.length>=2? f : HIST; })();
849
+ const SMALL_HIDDEN = HIST.length - TREND.length;
736
850
 
737
851
  const state = { q:'', status:'all', project:'all', dims:{}, selected:null, retry:null, group:'file', open:{}, view:'overview' };
738
852
  const DIMS = data.options.dimensions.filter(d=>data.tests.some(t=>t.meta[d]));
@@ -750,7 +864,8 @@ app.append(h('div',{class:'band'}, stripe(), header(), nav()),
750
864
  h('div',{class:'view','data-view':'failures'}, h('div',{class:'view-pad'}, failuresView())),
751
865
  h('div',{class:'view','data-view':'api'}, h('div',{class:'view-pad'}, apiView())),
752
866
  h('div',{class:'view','data-view':'graphs'}, graphsView()),
753
- hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null);
867
+ hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null,
868
+ rlFooter());
754
869
  select(state.selected || firstInteresting(), true);
755
870
  showView(state.view);
756
871
  window.addEventListener('keydown', e=>{
@@ -793,17 +908,20 @@ function failuresView(){
793
908
  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))); })))));
794
909
  return grid;
795
910
  }
911
+ /** A 4xx/5xx is a failure only when the attempt that made the call failed. In a passing attempt it is the
912
+ * answer the test asked for (a 404 after a DELETE, a 401 for a bad token) — shown as "expected", never red. */
913
+ function apiCls(c,r){ const st=c.status||0; return st>=400 ? (r&&r.status==='passed'?'exp':'bad') : st>=300?'warn':'ok'; }
796
914
  function apiView(){
797
- 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});
915
+ 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)});
798
916
  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().');
799
- 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;
800
- calls.sort((a,b)=>((b.c.status||0)>=400)-((a.c.status||0)>=400)||(b.c.duration||0)-(a.c.duration||0));
917
+ 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;
918
+ calls.sort((a,b)=>(b.cls==='bad')-(a.cls==='bad')||(b.c.duration||0)-(a.c.duration||0));
801
919
  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){} }
802
- 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'))),
920
+ 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'))),
803
921
  h('div',{class:'card w12'}, h('table',{class:'apitbl'}, h('thead',{}, h('tr',{}, ['Method','URL','Status','Time','Test'].map(x=>h('th',{},x)))),
804
- 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)},
922
+ h('tbody',{}, calls.map(({c,t,cls})=>{ const st=c.status||0; return h('tr',{onclick:()=>select(t.id)},
805
923
  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())),
806
- 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)); })))));
924
+ 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)); })))));
807
925
  }
808
926
  function timelineView(){
809
927
  const W=data.options.widgets, cards=[];
@@ -819,8 +937,9 @@ function graphsView(){
819
937
  return h('div',{class:'view-pad'},
820
938
  h('div',{class:'grid graphs-grid'},
821
939
  chartCard('outcomes-by-priority', 'Outcomes by priority', 'Stacked pass / fail / flaky / skipped for each priority tier.', chartPriorityOutcome),
940
+ 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,
822
941
  chartCard('failure-categories', 'Failure categories', 'What kinds of failures are hurting the suite.', chartFailureCategories),
823
- chartCard('duration-distribution','Duration distribution', 'How test durations are spread across the run.', chartDurationHistogram),
942
+ chartCard('duration-distribution','Duration distribution', 'How the durations of the tests that ran are spread.', chartDurationHistogram),
824
943
  chartCard('trend-by-priority', 'Pass rate trend by priority', 'How each priority tier trends over the last runs.', chartTrendByPriority),
825
944
  chartCard('owner-leaderboard', 'Owner leaderboard', 'Failures and flakes ranked by owner. Add meta({owner:...}) to see this.', chartOwnerLeaderboard),
826
945
  ));
@@ -875,6 +994,29 @@ function chartCard(id, title, sub, chartFn){
875
994
  }
876
995
 
877
996
  /* Chart 1: horizontal stacked bar per priority (P0..P4 in order, plus 'no priority') */
997
+ /* Chart 1b: the same stacked bars per Playwright project (chromium, firefox, Microsoft Edge...), with each
998
+ project's pass rate, so a browser that fails more than the others stands out. */
999
+ function chartProjectOutcome(){
1000
+ if(data.projects.length<2) return null;
1001
+ const outs=['passed','flaky','failed','timedOut','interrupted','skipped'];
1002
+ const colors={passed:'var(--pass)', flaky:'var(--flaky)', failed:'var(--fail)', timedOut:'var(--fail)', interrupted:'var(--fail)', skipped:'var(--skip)'};
1003
+ 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]));
1004
+ if(!rows.length) return null;
1005
+ const tot=r=>outs.reduce((a,o)=>a+r.m[o],0), maxN=Math.max(...rows.map(tot));
1006
+ const longest=Math.max(...rows.map(r=>r.p.length));
1007
+ const W=520, rowH=28, gap=10, labW=Math.min(170,Math.max(70,longest*7+10)), barW=W-labW-96;
1008
+ const H=rows.length*(rowH+gap)+30;
1009
+ const parts=rows.map((r,i)=>{
1010
+ 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;
1011
+ let x=labW; const segs=[];
1012
+ 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; }
1013
+ return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12" font-weight="600">'+gEsc(r.p)+'</text>'+segs.join('')+
1014
+ '<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>';
1015
+ }).join('');
1016
+ 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>');
1017
+ const legendSvg='<g transform="translate('+labW+','+(H-2)+')">'+legend.map((g,i)=>'<g transform="translate('+(i*90)+',0)">'+g+'</g>').join('')+'</g>';
1018
+ 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>'});
1019
+ }
878
1020
  function chartPriorityOutcome(){
879
1021
  const order = ['P0','P1','P2','P3','P4'];
880
1022
  const outs = ['passed','flaky','failed','timedOut','interrupted','skipped'];
@@ -929,7 +1071,7 @@ function chartDurationHistogram(){
929
1071
  {label:'30s+', min:30000,max:Infinity},
930
1072
  ];
931
1073
  const counts = buckets.map(()=>0);
932
- 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; } } }
1074
+ 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; } } }
933
1075
  const maxN = Math.max(1,...counts);
934
1076
  const W=520, colW=80, gap=16, padL=32, padT=24, padB=36, chartH=180;
935
1077
  const H = chartH+padT+padB;
@@ -944,7 +1086,7 @@ function chartDurationHistogram(){
944
1086
 
945
1087
  /* Chart 4: multi-line — pass rate per priority over the last N history entries */
946
1088
  function chartTrendByPriority(){
947
- const hist = data.history || [];
1089
+ const hist = TREND;
948
1090
  if(hist.length < 2) return null;
949
1091
  const orders = ['P0','P1','P2','P3','P4'];
950
1092
  const seenPri = new Set();
@@ -1045,6 +1187,7 @@ function header(){
1045
1187
  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){} }},
1046
1188
  [['lab','Blue'],['ocean','Ocean'],['ember','Ember'],['mono','Mono']].map(([v,l])=>h('option',{value:v,selected:html.getAttribute('data-palette')===v},l)));
1047
1189
  const copy = h('button',{class:'btn',onclick:e=>copyText(summaryMarkdown(),e.currentTarget,'Copied for Slack')}, 'Copy summary');
1190
+ 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');
1048
1191
  return h('header',{class:'hdr'},
1049
1192
  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)),
1050
1193
  data.options.project ? h('div',{class:'proj-blk'}, h('h1',{}, data.title),
@@ -1055,7 +1198,17 @@ function header(){
1055
1198
  h('div',{class:'hmeta'}, Object.entries(data.metadata).map(([k,v])=>h('span',{class:'chip'}, k+' ', h('b',{},v)))),
1056
1199
  h('div',{class:'spacer'}),
1057
1200
  h('span',{class:'when'}, new Date(data.startTime).toLocaleString()+' · '+ms(data.duration)+' · '+data.workers+' worker'+(data.workers===1?'':'s')),
1058
- h('div',{class:'ctl'}, copy, pal, toggle));
1201
+ h('div',{class:'ctl'}, copy, pdf, pal, toggle));
1202
+ }
1203
+
1204
+ /* ---------- footer: reportingLabs branding ---------- */
1205
+ function rlFooter(){
1206
+ const src=document.getElementById('rl-logo').content.cloneNode(true).querySelector('svg');
1207
+ const svg=src.outerHTML.replace(/(id="|url\(#|href="#)(rl-[a-z]+)/g,'$1$2-f'); // unique ids vs the header's copy
1208
+ return h('footer',{class:'rl-foot'},
1209
+ h('span',{class:'rl-foot-mark',html:svg}),
1210
+ h('span',{class:'rl-foot-txt'}, 'Generated by ', h('b',{},'reportingLabs'),
1211
+ data.generatedAt? ' · '+new Date(data.generatedAt).toLocaleString() : null));
1059
1212
  }
1060
1213
 
1061
1214
  /* ---------- summary ---------- */
@@ -1085,12 +1238,12 @@ function summary(){
1085
1238
  const hasFail=data.tests.some(t=>isFail(t.outcome)||t.outcome==='flaky');
1086
1239
  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)));
1087
1240
  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));
1088
- if(W.dimensions) g.push(h('div',{class:'card w8'}, h('h2',{},'Breakdown', h('span',{class:'hint'},'click a row to filter')), breakdown()));
1241
+ 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()));
1089
1242
  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]]))); }
1090
1243
  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()));
1091
1244
  if(W.skipped!==false) { const sc=skippedCard(); if(sc) g.push(sc); }
1092
1245
  if(W.environment!==false) { const ec=envCard(); if(ec) g.push(ec); }
1093
- 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()));
1246
+ 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()));
1094
1247
  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.')));
1095
1248
  const ge=data.globalErrors||[], go=data.globalOutput||[];
1096
1249
  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')),
@@ -1104,10 +1257,15 @@ function pill(key,text,n,color,sub,tone,series){
1104
1257
  series? spark(series,color) : null, h('div',{class:'l'},text), h('div',{class:'v'},n), h('div',{class:'d '+(tone||'')},sub||''));
1105
1258
  }
1106
1259
  function initials(name){ return (name||'').split(/[\s\-–—·]+/).filter(Boolean).slice(0,2).map(w=>w[0]).join('').toUpperCase()||'R'; }
1260
+ /* The run this one is compared with: the latest earlier run of a comparable size (a full run is not compared
1261
+ with a one-spec debug run, nor a debug run with the full suite); with a build label, a different build. */
1107
1262
  function prevRun(){
1108
1263
  const H=data.history||[]; if(H.length<2) return null;
1109
- 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]; }
1110
- return H[H.length-2];
1264
+ const cur=H[H.length-1], n=cur.total||0;
1265
+ const comparable=e=> isSmallRun(cur) ? (e.total||0)>=n/2 && (e.total||0)<=n*2 : !isSmallRun(e);
1266
+ let fallback=null;
1267
+ 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]; }
1268
+ return fallback;
1111
1269
  }
1112
1270
 
1113
1271
  /* ---------- run history per test ---------- */
@@ -1155,7 +1313,7 @@ function slowerView(){
1155
1313
  const rows=regressions().slice(0,6);
1156
1314
  if(!rows.length) return h('div',{class:'ok'},'No test got 2× slower than last run.');
1157
1315
  return h('ul',{class:'slow'}, rows.map(({t,prev,cur,ratio})=>h('li',{}, h('button',{onclick:()=>select(t.id)},
1158
- 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)'}))),
1316
+ 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)'}))),
1159
1317
  h('span',{class:'d'}, h('b',{style:'color:var(--fail)'},'×'+(ratio>=10?Math.round(ratio):ratio.toFixed(1))), ' ', ms(prev)+' → '+ms(cur))))));
1160
1318
  }
1161
1319
  /* sparklines from the whole history (counts, no per-test data needed) */
@@ -1166,7 +1324,7 @@ function spark(series, color, w, h2){
1166
1324
  const pts=series.map((v,i)=>x(i)+','+y(v)).join(' ');
1167
1325
  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>'});
1168
1326
  }
1169
- function hseries(f){ return HIST.slice(-12).map(f); }
1327
+ function hseries(f){ return TREND.slice(-12).map(f); }
1170
1328
  /* owner rollup for the Failures view and the copied summary */
1171
1329
  function byOwner(){
1172
1330
  const m=new Map();
@@ -1304,13 +1462,28 @@ function donut(){
1304
1462
  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;
1305
1463
  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)))));
1306
1464
  }
1465
+ /* Cross-project signal. The same test (file, describe path, title) runs once per project — a browser, a
1466
+ device. Failing in every project it ran in points at the app or the test itself; failing in some projects
1467
+ only points at those browsers. Skipped runs do not count; a flaky pass counts as a pass. */
1468
+ var XP=null; // var: the views are built before this line runs
1469
+ function xpKey(t){ return t.file+'::'+t.path.join('/')+'::'+t.title; }
1470
+ function xproj(t){
1471
+ if(data.projects.length<2) return null;
1472
+ 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); } }
1473
+ const g=XP.get(xpKey(t))||[]; if(g.length<2) return null;
1474
+ const bad=g.filter(u=>isFail(u.outcome)); if(!bad.length) return null;
1475
+ 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'};
1476
+ const names=bad.map(u=>u.project), good=g.filter(u=>!isFail(u.outcome)).map(u=>u.project);
1477
+ 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(', ')};
1478
+ }
1479
+ function xpTag(xp,cls){ return h('span',{class:'xp '+(xp.all?'all':'some')+(cls?' '+cls:''),title:xp.tip},xp.text); }
1307
1480
  function attention(limit){
1308
1481
  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);
1309
1482
  if(!bad.length) return h('div',{class:'ok'},'No failures. Ship it.');
1310
1483
  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); }
1311
1484
  const all=[...groups.values()], rows=limit?all.slice(0,limit):all;
1312
1485
  const sub=(t,projects)=>{ const parts=[t.file.split('/').pop()];
1313
- if(data.projects.length>1) parts.push(projects.length>1? projects.length+' projects' : projects[0]);
1486
+ if(data.projects.length>1){ const xp=xproj(t); parts.push(xp? xpTag(xp) : projects.length>1? projects.length+' projects' : projects[0]); }
1314
1487
  const si=sinceInfo(t); if(si) parts.push(si.kind==='new'? h('b',{class:'new'},'new this run') : 'failing since '+runLabel(si.since));
1315
1488
  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));
1316
1489
  else if(t.outcome==='flaky') parts.push('passed on retry '+t.results.length);
@@ -1394,7 +1567,7 @@ function clusterLine(c){
1394
1567
  // in place and "show fewer" folds it again. Each name opens that test.
1395
1568
  function clusterWho(byTitle, all){
1396
1569
  const entries=[...byTitle.entries()], names=all?entries:entries.slice(0,6), extra=entries.length-names.length;
1397
- 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));
1570
+ 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; })()));
1398
1571
  const swap=(ev,toAll)=>{ const d=ev.currentTarget.closest('.who'); d.replaceChildren(); for(const n of clusterWho(byTitle,toAll)) d.append(n); };
1399
1572
  if(extra>0) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,true),title:'Show all '+entries.length+' tests'},'+'+extra+' more'));
1400
1573
  else if(all&&entries.length>6) who.push(h('button',{class:'ex',onclick:ev=>swap(ev,false)},'show fewer'));
@@ -1408,7 +1581,7 @@ function clustersView(cl){
1408
1581
  }));
1409
1582
  }
1410
1583
  function trend(){
1411
- const H=data.history, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
1584
+ const H=TREND, W=760, HT=170, padL=34, padR=12, padT=12, padB=26, n=H.length;
1412
1585
  const x=i=>padL+(n===1?0:i/(n-1))*(W-padL-padR), y=v=>padT+(1-v/100)*(HT-padT-padB);
1413
1586
  const rate=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.passed/ran*100):0; };
1414
1587
  const failPct=e=>{ const ran=e.total-e.skipped; return ran?Math.round(e.failed/ran*100):0; };
@@ -1418,9 +1591,14 @@ function trend(){
1418
1591
  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"/>';
1419
1592
  svg+='<polyline class="fail" points="'+H.map((e,i)=>x(i)+','+y(failPct(e))).join(' ')+'"/>';
1420
1593
  svg+='<polyline class="pass" points="'+H.map((e,i)=>x(i)+','+y(rate(e))).join(' ')+'"/>';
1421
- 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('');
1422
- const step=Math.max(1,Math.ceil(n/8));
1423
- 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('');
1594
+ 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('');
1595
+ // Several runs on one day get the time too; longer labels mean fewer of them, so they never overlap
1596
+ const day=e=>new Date(e.time).toLocaleDateString(undefined,{month:'short',day:'numeric'});
1597
+ const days=H.filter(e=>!e.label).map(day), twice=days.length!==new Set(days).size;
1598
+ const step=Math.max(1,Math.ceil(n/(twice?5:8)));
1599
+ const shown=H.map((e,i)=>i===n-1||(i%step===0&&n-1-i>=step));
1600
+ const axis=e=> e.label || (twice ? day(e)+' '+new Date(e.time).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'}) : day(e));
1601
+ 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('');
1424
1602
  const colW=n>1?(x(1)-x(0)):(W-padL-padR);
1425
1603
  svg+='<line class="guide" x1="0" x2="0" y1="'+padT+'" y2="'+y(0)+'" style="display:none"/>';
1426
1604
  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('');
@@ -1445,7 +1623,7 @@ function trend(){
1445
1623
  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); });
1446
1624
  svgEl.addEventListener('mouseleave',()=>{ tip.hidden=true; guide.style.display='none'; });
1447
1625
  wrap.append(
1448
- 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))));
1626
+ 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':''))));
1449
1627
  return wrap;
1450
1628
  }
1451
1629
  function workers(){
@@ -1484,7 +1662,7 @@ function slowest(){
1484
1662
  const top=[...data.tests].filter(t=>t.outcome!=='skipped').sort((a,b)=>b.duration-a.duration).slice(0,5);
1485
1663
  const max=top[0]?top[0].duration:1;
1486
1664
  return h('ul',{class:'slow'}, top.map(t=>h('li',{}, h('button',{onclick:()=>select(t.id)},
1487
- 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))))));
1665
+ 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))))));
1488
1666
  }
1489
1667
  function projects(){
1490
1668
  return h('div',{class:'proj'}, data.projects.map(p=>{
@@ -1580,30 +1758,31 @@ function select(id, quiet){
1580
1758
  state.selected=id; state.retry=t.results.length-1; if(!quiet){ if(state.view!=='tests') showView('tests'); else setHash(); } refresh(); renderDetail(t);
1581
1759
  const it=document.querySelector('.item[data-id="'+id+'"]'); if(it&&it.scrollIntoView) it.scrollIntoView({block:'nearest'});
1582
1760
  }
1583
- function renderDetail(t){
1584
- const d=$('#detail'); d.innerHTML='';
1761
+ function renderDetail(t, d, retryIdx){
1762
+ d=d||$('#detail'); d.innerHTML='';
1763
+ const retry = retryIdx==null ? state.retry : retryIdx;
1585
1764
  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))),
1586
1765
  h('div',{class:'titlebar'}, h('h3',{},t.title), h('div',{class:'actions'},
1587
1766
  h('button',{class:'btn',onclick:e=>copyText(location.href.split('#')[0]+'#t='+t.id,e.currentTarget,'Link copied')},'Copy link'),
1588
1767
  (()=>{ 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; })(),
1589
1768
  editorLink(t))),
1590
- 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:''))));
1769
+ 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:''))));
1591
1770
  const metaKeys=Object.keys(t.meta);
1592
1771
  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; };
1593
1772
  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); })));
1594
1773
  const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
1595
1774
  if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
1596
1775
  if(t.results.length>1){
1597
- 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)))));
1776
+ 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)))));
1598
1777
  }
1599
- const r=t.results[state.retry]; if(!r){ d.append(h('p',{class:'empty'},'This test did not run.')); return; }
1778
+ const r=t.results[retry]; if(!r){ d.append(h('p',{class:'empty'},'This test did not run.')); return; }
1600
1779
  const body=h('div',{});
1601
- if(t.note&&state.retry===t.results.length-1) body.append(h('div',{class:'note '+(t.outcome==='passed'?'ok':'warn')}, t.note));
1780
+ if(t.note&&retry===t.results.length-1) body.append(h('div',{class:'note '+(t.outcome==='passed'?'ok':'warn')}, t.note));
1602
1781
  if(r.errors.length){ body.append(h('h4',{},r.errors.length>1?'Errors':'Error'), ...r.errors.map(errorView)); }
1603
1782
  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)); }
1604
1783
  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))))); }
1605
1784
  for(const b of r.data){ body.append(h('h4',{},b.name), dataBlock(b)); }
1606
- 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 ****')); }
1785
+ 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 ****')); }
1607
1786
  const imgs=r.attachments.filter(a=>a.src&&a.contentType.startsWith('image/'));
1608
1787
  const vids=r.attachments.filter(a=>a.src&&a.contentType.startsWith('video/'));
1609
1788
  const traces=r.attachments.filter(a=>a.src&&(a.name==='trace'||/\.zip$/.test(a.src)));
@@ -1648,10 +1827,10 @@ function dataBlock(b){
1648
1827
  if(b.kind==='kv') return h('dl',{class:'kv2'}, b.kv.map(([k,v])=>[h('dt',{},k),h('dd',{class:v==='****'?'mask':''},v)]));
1649
1828
  return h('pre',{class:'txt'},b.text);
1650
1829
  }
1651
- function apiPanel(c){
1652
- const st=c.status||0, cls=st>=500||st>=400?'bad':st>=300?'warn':'ok';
1830
+ function apiPanel(c,r){
1831
+ const st=c.status||0, cls=apiCls(c,r);
1653
1832
  const wrap=h('div',{class:'api collapsed'});
1654
- 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);
1833
+ 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);
1655
1834
  const pre=v=>v==null?null:h('pre',{}, typeof v==='string'?v:JSON.stringify(v,null,2));
1656
1835
  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'));
1657
1836
  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'));
@@ -1754,5 +1933,124 @@ function openBugReport(t){
1754
1933
  }
1755
1934
  function lightbox(src){ const lb=h('div',{class:'lb',onclick:()=>lb.remove()}, h('img',{src})); document.body.append(lb); }
1756
1935
  function escape(s){ return s.replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
1936
+
1937
+ /* ---------- print / PDF ----------
1938
+ One builder serves both the "Export PDF" button (window.print) and headless page.pdf():
1939
+ Chromium fires 'beforeprint' for both. We assemble the whole report — cover, overview,
1940
+ failures, graphs, API and every test's detail expanded — into a .printdoc, force the light
1941
+ theme for paper, print, then tear it all down so the live app is untouched. Video is skipped. */
1942
+ function buildPrintDoc(){
1943
+ if(document.querySelector('.printdoc')) return;
1944
+ const html=document.documentElement;
1945
+ html.setAttribute('data-rl-theme-prev', html.getAttribute('data-theme')||'');
1946
+ html.setAttribute('data-theme','light'); // always light on paper, whatever the screen theme
1947
+ const pd=h('div',{class:'printdoc'});
1948
+ 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;
1949
+ const stat=(n,l,c)=>h('div',{class:'pstat'}, h('div',{class:'n',style:c?'color:'+c:''},String(n)), h('div',{class:'l'},l));
1950
+ let logo;
1951
+ if(data.options.logo){ logo = h('img',{src:data.options.logo,alt:'',style:'width:40px;height:40px;border-radius:8px'}); }
1952
+ else {
1953
+ // The live header already holds one copy of this SVG; in print that copy is display:none, and its
1954
+ // gradient/clip ids would win over an identical clone (first id in the document). Rewrite the ids so
1955
+ // the cover's logo paints its gradients and flask clip correctly.
1956
+ const src=document.getElementById('rl-logo').content.cloneNode(true).querySelector('svg');
1957
+ const html2=src.outerHTML.replace(/(id="|url\(#|href="#)(rl-[a-z]+)/g,'$1$2-p');
1958
+ const holder=h('span',{class:'logo',style:'display:inline-flex;width:40px;height:40px',html:html2});
1959
+ logo=holder;
1960
+ }
1961
+ const verdict = f ? (f+' of '+s.total+' test'+(s.total===1?'':'s')+' failed'+(s.flaky?', '+s.flaky+' flaky':''))
1962
+ : (ran===0 ? 'No tests ran' : 'All '+ran+' test'+(ran===1?'':'s')+' passed'+(s.flaky?', '+s.flaky+' flaky':'')+(s.skipped?' · '+s.skipped+' skipped':''));
1963
+ // ---- page 1: executive summary ----
1964
+ pd.append(h('div',{class:'pcover'},
1965
+ h('div',{class:'pcover-top'}, logo,
1966
+ h('div',{}, h('div',{class:'pcover-title'}, data.title),
1967
+ 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(' · ')+' · ':'')
1968
+ + new Date(data.startTime).toLocaleString()+' · '+ms(data.duration)+' · '+data.workers+' worker'+(data.workers===1?'':'s')))),
1969
+ 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,
1970
+ h('div',{class:'pverdict '+(f?'bad':'ok')}, verdict),
1971
+ h('div',{class:'pcover-stats'},
1972
+ stat(rate+'%','Pass rate'), stat(s.passed,'Passed','var(--pass)'),
1973
+ f?stat(f,'Failed','var(--fail)'):null, s.flaky?stat(s.flaky,'Flaky','var(--flaky)'):null,
1974
+ s.skipped?stat(s.skipped,'Skipped','var(--ink-3)'):null, stat(s.total,'Total'))));
1975
+ const pexec=h('div',{class:'pexec'});
1976
+ pexec.append(h('div',{class:'pexec-card'}, h('div',{class:'psub-h'},'Pass rate'), donut()));
1977
+ const envc=printEnvCard(); if(envc) pexec.append(envc);
1978
+ pd.append(pexec);
1979
+
1980
+ // The first section flows right after the executive summary so page 1 is never half-empty;
1981
+ // the rest start on a fresh page.
1982
+ let first=true;
1983
+ const sec=(title,node)=>{ if(!node) return; pd.append(h('section',{class:'psec'+(first?'':' brk')}, h('h2',{class:'psec-h'},title), node)); first=false; };
1984
+ // ---- analytics, failure analysis, full inventory, evidence ----
1985
+ if(data.tests.length) sec('Test analytics', graphsView());
1986
+ if(f + s.flaky > 0){
1987
+ const fn=failuresView();
1988
+ // On paper there is no "+N more" to click, so expand every cluster to list all its tests.
1989
+ fn.querySelectorAll('.clu2 .who .ex').forEach(b=>{ try{ b.click(); }catch(e){} });
1990
+ sec('Failure analysis', fn);
1991
+ }
1992
+ sec('All test cases', allTestsTable());
1993
+ const bad=[...data.tests].filter(t=>isFail(t.outcome)||t.outcome==='flaky').sort((a,b)=>rank(a)-rank(b)||a.title.localeCompare(b.title));
1994
+ if(bad.length){ const ev=h('div',{}); for(const t of bad) ev.append(printEvidence(t)); sec('Failure evidence', ev); }
1995
+ pd.append(h('div',{class:'pfoot'}, 'Generated by reportingLabs · '+new Date().toLocaleString()));
1996
+ document.body.append(pd);
1997
+ }
1998
+ function printEnvCard(){
1999
+ if(!data.env||!data.env.length) return null;
2000
+ return h('div',{class:'pexec-card'}, h('div',{class:'psub-h'},'Environment'),
2001
+ 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)])));
2002
+ }
2003
+ function allTestsTable(){
2004
+ 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;
2005
+ const cols=[['c-n','#'],['','Test'],['','Spec'], hasPri?['c-pri','Priority']:null, hasOwn?['c-own','Owner']:null, ['c-st','Status'], ['c-dur','Duration']].filter(Boolean);
2006
+ 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));
2007
+ let i=0;
2008
+ return h('table',{class:'ptbl'},
2009
+ h('colgroup',{}, cols.map(([c])=>h('col',{class:c||''}))),
2010
+ h('thead',{}, h('tr',{}, cols.map(([,l])=>h('th',{},l)))),
2011
+ h('tbody',{}, order.map(t=>{ i++; return h('tr',{}, [
2012
+ h('td',{class:'num'}, String(i)),
2013
+ h('td',{}, h('div',{class:'pt-name'}, t.title), t.path.length? h('div',{class:'pt-sub'}, t.path.join(' › ')) : null),
2014
+ h('td',{}, h('span',{class:'pt-spec'}, t.file.split('/').pop()+(multiProj?' · '+t.project:''))),
2015
+ hasPri? h('td',{}, [t.meta.priority,t.meta.severity].filter(Boolean).join(' · ')) : null,
2016
+ hasOwn? h('td',{}, t.meta.owner||'') : null,
2017
+ h('td',{}, h('span',{class:'pstatus'}, h('span',{class:'st '+t.outcome,style:'background:'+colorOf(bucket(t))}), label[t.outcome])),
2018
+ h('td',{class:'num'}, ms(t.duration)),
2019
+ ].filter(x=>x!==null)); })));
2020
+ }
2021
+ function printEvidence(t){
2022
+ const r=t.results[t.results.length-1], e=r&&r.errors[0];
2023
+ const art=h('article',{class:'pev'});
2024
+ 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])));
2025
+ 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:'')));
2026
+ if(e){ if(e.explain) art.append(whyView(e.explain)); art.append(errorBody(e)); }
2027
+ 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.'));
2028
+ const imgs=(r?r.attachments:[]).filter(a=>a.src&&(a.contentType||'').startsWith('image/')&&!/-(expected|actual|diff)(\.\w+)?$/.test(a.name));
2029
+ 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)))));
2030
+ return art;
2031
+ }
2032
+ function teardownPrintDoc(){
2033
+ const pd=document.querySelector('.printdoc'); if(pd) pd.remove();
2034
+ const html=document.documentElement, prev=html.getAttribute('data-rl-theme-prev');
2035
+ if(prev!=null){ if(prev) html.setAttribute('data-theme',prev); else html.removeAttribute('data-theme'); html.removeAttribute('data-rl-theme-prev'); }
2036
+ }
2037
+ // Build the print document and wait for every screenshot (a data: URI) to actually decode, so it
2038
+ // is painted — otherwise a print fired right after the DOM is built captures blank image boxes.
2039
+ async function preparePrint(){
2040
+ buildPrintDoc();
2041
+ try{ await Promise.all([...document.querySelectorAll('.printdoc img')].map(im=> im.decode? im.decode().catch(()=>{}) : Promise.resolve())); }catch(e){}
2042
+ return document.querySelectorAll('.printdoc img').length;
2043
+ }
2044
+ async function exportPdf(){
2045
+ await preparePrint();
2046
+ try{ window.print(); }catch(e){}
2047
+ }
2048
+ // A headless driver (the reporter's auto-generated report.pdf) awaits this, then calls page.pdf().
2049
+ window.reportingLabsPreparePrint = preparePrint;
2050
+ // A plain headless Chrome launched with --print-to-pdf on this file with ?rl-print (how the Java/CLI
2051
+ // generator renders report.pdf) has no way to call the hook, so build the print document on load instead.
2052
+ if(/[?&#]rl-print\b/.test(location.href)){ if(document.readyState!=='loading') preparePrint(); else window.addEventListener('DOMContentLoaded', preparePrint); }
2053
+ window.addEventListener('beforeprint', buildPrintDoc); // native Ctrl+P: build if not already built
2054
+ window.addEventListener('afterprint', teardownPrintDoc);
1757
2055
  })();
1758
2056
  `;