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/README.md +35 -4
- package/dist/explain.d.ts +1 -1
- package/dist/explain.js +4 -0
- package/dist/pdf.d.ts +14 -0
- package/dist/pdf.js +149 -0
- package/dist/reporter.d.ts +14 -0
- package/dist/reporter.js +15 -0
- package/dist/template.html +335 -37
- package/dist/template.js +335 -37
- package/dist/types.d.ts +10 -0
- package/dist/wdio/env.d.ts +4 -0
- package/dist/wdio/env.js +62 -0
- package/dist/wdio/finalize.d.ts +39 -0
- package/dist/wdio/finalize.js +189 -0
- package/dist/wdio/index.d.ts +17 -0
- package/dist/wdio/index.js +22 -0
- package/dist/wdio/reporter.d.ts +67 -0
- package/dist/wdio/reporter.js +332 -0
- package/package.json +10 -1
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.
|
|
800
|
-
calls.sort((a,b)=>(
|
|
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
|
|
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
|
|
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 =
|
|
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 '+
|
|
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]
|
|
1110
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
1423
|
-
|
|
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(
|
|
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
|
-
|
|
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':
|
|
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[
|
|
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&&
|
|
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=
|
|
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=>({'&':'&','<':'<','>':'>','"':'"'}[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
|
`;
|