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