reporting-labs 0.6.0 → 0.6.2
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 +179 -22
- package/bin/build-template.js +101 -0
- package/dist/auto.js +52 -15
- package/dist/meta.d.ts +3 -0
- package/dist/meta.js +7 -1
- package/dist/reporter.d.ts +9 -1
- package/dist/reporter.js +65 -5
- package/dist/template.html +1659 -0
- package/dist/template.js +219 -4
- package/dist/types.d.ts +22 -7
- package/package.json +2 -2
package/dist/template.js
CHANGED
|
@@ -660,6 +660,12 @@ a.btn{text-decoration:none}
|
|
|
660
660
|
.skp{list-style:none;margin:0;padding:0} .skp li{border-top:1px solid var(--line)} .skp li:first-child{border-top:0}
|
|
661
661
|
.skp button{width:100%;text-align:left;padding:8px 4px;display:flex;flex-direction:column;gap:2px}
|
|
662
662
|
.skp .t{font-size:13px;font-weight:500;color:var(--ink)} .skp .r{font-size:12px;color:var(--ink-3)}
|
|
663
|
+
.gchart .gchart-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}
|
|
664
|
+
.gchart .gchart-head h2{margin:0}
|
|
665
|
+
.gchart .gchart-sub{color:var(--ink-3);font-size:12px;margin-top:4px}
|
|
666
|
+
.gchart .gchart-body{margin-top:14px}
|
|
667
|
+
.gchart .empty{padding:22px 0;color:var(--ink-3);text-align:center}
|
|
668
|
+
.graphs-grid .card{background:var(--surface)}
|
|
663
669
|
.kv.env{grid-template-columns:max-content 1fr;gap:8px 16px} .kv.env dt{font-size:11.5px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;padding-top:1px} .kv.env dd{font:12.5px var(--mono);word-break:break-word} .kv.env a{color:var(--accent-2)}
|
|
664
670
|
.hm-wrap{overflow-x:auto} .hm{border-collapse:separate;border-spacing:4px;font-size:12.5px;min-width:100%}
|
|
665
671
|
.hm th{font-weight:600;color:var(--ink-2);text-align:left;padding:4px 8px;white-space:nowrap} .hm thead th{font:600 11px var(--mono);color:var(--ink-3);text-align:center;letter-spacing:.03em}
|
|
@@ -740,6 +746,7 @@ app.append(h('div',{class:'band'}, stripe(), header(), nav()),
|
|
|
740
746
|
h('div',{class:'view','data-view':'tests'}, main()),
|
|
741
747
|
h('div',{class:'view','data-view':'failures'}, h('div',{class:'view-pad'}, failuresView())),
|
|
742
748
|
h('div',{class:'view','data-view':'api'}, h('div',{class:'view-pad'}, apiView())),
|
|
749
|
+
h('div',{class:'view','data-view':'graphs'}, graphsView()),
|
|
743
750
|
hasTimelineView()? h('div',{class:'view','data-view':'timeline'}, h('div',{class:'view-pad'}, timelineView())) : null);
|
|
744
751
|
select(state.selected || firstInteresting(), true);
|
|
745
752
|
showView(state.view);
|
|
@@ -747,7 +754,7 @@ window.addEventListener('keydown', e=>{
|
|
|
747
754
|
if(e.key==='Escape'){const lb=$('.lb'); if(lb) lb.remove(); return;}
|
|
748
755
|
const tag=(e.target.tagName||'').toLowerCase(); if(tag==='input'||tag==='select'||tag==='textarea') return;
|
|
749
756
|
if(e.key==='/'){ e.preventDefault(); showView('tests'); $('.tools input').focus(); return; }
|
|
750
|
-
const vk={'1':'overview','2':'tests','3':'failures','4':'api','5':'timeline'}[e.key]; if(vk&&document.querySelector('.nav button[data-view='+vk+']')){ showView(vk); return; }
|
|
757
|
+
const vk={'1':'overview','2':'tests','3':'failures','4':'api','5':'graphs','6':'timeline'}[e.key]; if(vk&&document.querySelector('.nav button[data-view='+vk+']')){ showView(vk); return; }
|
|
751
758
|
if(e.key==='f'){ state.status=state.status==='failed'?'all':'failed'; refresh(); return; }
|
|
752
759
|
if(e.key==='j'||e.key==='k'){ const ids=[...document.querySelectorAll('.item')].map(i=>i.dataset.id); if(!ids.length) return; let i=ids.indexOf(state.selected); i=e.key==='j'?Math.min(ids.length-1,i+1):Math.max(0,i-1); select(ids[i]); }
|
|
753
760
|
});
|
|
@@ -762,7 +769,7 @@ function showView(v){
|
|
|
762
769
|
}
|
|
763
770
|
function nav(){
|
|
764
771
|
const s=data.stats, f=s.failed+s.timedOut+s.interrupted, apiN=data.tests.reduce((a,t)=>a+t.results.reduce((b,r)=>b+r.api.length,0),0);
|
|
765
|
-
const tabs=[['overview','Overview',null],['tests','Tests',s.total],['failures','Failures',f+s.flaky,f>0],['api','API',apiN],['timeline','Timeline',null]].filter(t=>(t[0]!=='api'||apiN>0)&&(t[0]!=='timeline'||hasTimelineView()));
|
|
772
|
+
const tabs=[['overview','Overview',null],['tests','Tests',s.total],['failures','Failures',f+s.flaky,f>0],['api','API',apiN],['graphs','Graphs',null],['timeline','Timeline',null]].filter(t=>(t[0]!=='api'||apiN>0)&&(t[0]!=='timeline'||hasTimelineView())&&(t[0]!=='graphs'||data.tests.length>0));
|
|
766
773
|
return h('nav',{class:'nav'}, h('div',{}, tabs.map(([v,l,n,bad])=>h('button',{'data-view':v,'aria-selected':state.view===v,onclick:()=>showView(v)}, l, n!=null?h('span',{class:'cnt'+(bad?' bad':'')},n):null))));
|
|
767
774
|
}
|
|
768
775
|
function failuresView(){
|
|
@@ -802,6 +809,214 @@ function timelineView(){
|
|
|
802
809
|
return h('div',{class:'grid'}, cards);
|
|
803
810
|
}
|
|
804
811
|
function hasTimelineView(){ return data.options.widgets.timeline!==false || data.options.widgets.durations!==false; }
|
|
812
|
+
|
|
813
|
+
/* ---------- Graphs tab: 4 presentation-ready charts with PNG download ---------- */
|
|
814
|
+
function gEsc(s){ return String(s==null?'':s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
815
|
+
function graphsView(){
|
|
816
|
+
return h('div',{class:'view-pad'},
|
|
817
|
+
h('div',{class:'grid graphs-grid'},
|
|
818
|
+
chartCard('outcomes-by-priority', 'Outcomes by priority', 'Stacked pass / fail / flaky / skipped for each priority tier.', chartPriorityOutcome),
|
|
819
|
+
chartCard('failure-categories', 'Failure categories', 'What kinds of failures are hurting the suite.', chartFailureCategories),
|
|
820
|
+
chartCard('duration-distribution','Duration distribution', 'How test durations are spread across the run.', chartDurationHistogram),
|
|
821
|
+
chartCard('trend-by-priority', 'Pass rate trend by priority', 'How each priority tier trends over the last runs.', chartTrendByPriority),
|
|
822
|
+
chartCard('owner-leaderboard', 'Owner leaderboard', 'Failures and flakes ranked by owner. Add meta({owner:...}) to see this.', chartOwnerLeaderboard),
|
|
823
|
+
));
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/* Chart 5: horizontal stacked bar per owner — total tests, failed vs flaky highlighted */
|
|
827
|
+
function chartOwnerLeaderboard(){
|
|
828
|
+
const rows = new Map();
|
|
829
|
+
for(const t of data.tests){
|
|
830
|
+
const o = (t.meta.owner||'').toString().trim(); if(!o) continue;
|
|
831
|
+
let r = rows.get(o); if(!r){ r={owner:o, total:0, failed:0, flaky:0, passed:0, skipped:0}; rows.set(o,r); }
|
|
832
|
+
r.total++;
|
|
833
|
+
if(isFail(t.outcome)) r.failed++;
|
|
834
|
+
else if(t.outcome==='flaky') r.flaky++;
|
|
835
|
+
else if(t.outcome==='skipped') r.skipped++;
|
|
836
|
+
else if(t.outcome==='passed') r.passed++;
|
|
837
|
+
}
|
|
838
|
+
const list = [...rows.values()].sort((a,b)=> (b.failed - a.failed) || (b.flaky - a.flaky) || (b.total - a.total));
|
|
839
|
+
if(!list.length) return null;
|
|
840
|
+
const maxTotal = Math.max(...list.map(r=>r.total));
|
|
841
|
+
const W=520, rowH=28, gap=8, labW=110, barW=W-labW-70;
|
|
842
|
+
const H = list.length*(rowH+gap)+30;
|
|
843
|
+
const parts = list.map((r,i)=>{
|
|
844
|
+
const y=i*(rowH+gap)+14, scale=barW/maxTotal;
|
|
845
|
+
const passedW = r.passed*scale, flakyW = r.flaky*scale, failedW = r.failed*scale, skippedW = r.skipped*scale;
|
|
846
|
+
let x = labW;
|
|
847
|
+
const segs=[];
|
|
848
|
+
if(passedW>0){ segs.push('<rect x="'+x+'" y="'+y+'" width="'+passedW+'" height="'+rowH+'" fill="var(--pass)" rx="3"><title>'+gEsc(r.owner)+' · '+r.passed+' passed</title></rect>'); x+=passedW; }
|
|
849
|
+
if(flakyW>0){ segs.push('<rect x="'+x+'" y="'+y+'" width="'+flakyW+'" height="'+rowH+'" fill="var(--flaky)"><title>'+gEsc(r.owner)+' · '+r.flaky+' flaky</title></rect>'); x+=flakyW; }
|
|
850
|
+
if(failedW>0){ segs.push('<rect x="'+x+'" y="'+y+'" width="'+failedW+'" height="'+rowH+'" fill="var(--fail)"><title>'+gEsc(r.owner)+' · '+r.failed+' failed</title></rect>'); x+=failedW; }
|
|
851
|
+
if(skippedW>0){segs.push('<rect x="'+x+'" y="'+y+'" width="'+skippedW+'" height="'+rowH+'" fill="var(--skip)"><title>'+gEsc(r.owner)+' · '+r.skipped+' skipped</title></rect>'); x+=skippedW; }
|
|
852
|
+
return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12" font-weight="600">'+gEsc(r.owner)+'</text>'+segs.join('')+
|
|
853
|
+
'<text x="'+(labW+barW+8)+'" y="'+(y+rowH/2+4)+'" fill="var(--ink-2)" font-size="12" font-variant-numeric="tabular-nums">'+r.total+'</text>';
|
|
854
|
+
}).join('');
|
|
855
|
+
const legendPairs = [['Passed','var(--pass)'],['Flaky','var(--flaky)'],['Failed','var(--fail)'],['Skipped','var(--skip)']];
|
|
856
|
+
const hasKind = { Passed:list.some(r=>r.passed>0), Flaky:list.some(r=>r.flaky>0), Failed:list.some(r=>r.failed>0), Skipped:list.some(r=>r.skipped>0) };
|
|
857
|
+
const legend = legendPairs.filter(([n])=>hasKind[n]).map(([n,c],i)=>'<g transform="translate('+(labW+i*90)+','+(H-4)+')"><rect width="10" height="10" y="-9" fill="'+c+'"/><text x="14" y="0" font-size="11" fill="var(--ink-2)">'+n+'</text></g>').join('');
|
|
858
|
+
return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+(H+14)+'" width="100%">'+parts+legend+'</svg>'});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function chartCard(id, title, sub, chartFn){
|
|
862
|
+
const chart = chartFn();
|
|
863
|
+
const empty = !chart;
|
|
864
|
+
return h('div',{class:'card w6 gchart','data-chart':id},
|
|
865
|
+
h('div',{class:'gchart-head'},
|
|
866
|
+
h('div',{},
|
|
867
|
+
h('h2',{},title),
|
|
868
|
+
h('div',{class:'gchart-sub'},sub)),
|
|
869
|
+
empty? null : h('button',{class:'btn',title:'Download this chart as a PNG image',
|
|
870
|
+
onclick:e=>downloadChartPng(id, title, e.currentTarget)},'Download PNG')),
|
|
871
|
+
empty? h('div',{class:'empty'},'Not enough data yet. Add meta({ priority }) or run the suite once more.') : chart);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/* Chart 1: horizontal stacked bar per priority (P0..P4 in order, plus 'no priority') */
|
|
875
|
+
function chartPriorityOutcome(){
|
|
876
|
+
const order = ['P0','P1','P2','P3','P4'];
|
|
877
|
+
const outs = ['passed','flaky','failed','timedOut','interrupted','skipped'];
|
|
878
|
+
const colors= {passed:'var(--pass)', flaky:'var(--flaky)', failed:'var(--fail)', timedOut:'var(--fail)', interrupted:'var(--fail)', skipped:'var(--skip)'};
|
|
879
|
+
const buckets = new Map();
|
|
880
|
+
const put=(k,o)=>{ let m=buckets.get(k); if(!m){ m={}; for(const x of outs) m[x]=0; buckets.set(k,m); } m[o]=(m[o]||0)+1; };
|
|
881
|
+
for(const t of data.tests){ const p=(t.meta.priority||'').toString().trim().toUpperCase(); put(order.includes(p)? p : 'No priority', t.outcome); }
|
|
882
|
+
const rows = [...order.filter(k=>buckets.has(k)), ...(buckets.has('No priority')?['No priority']:[])];
|
|
883
|
+
if(!rows.length) return null;
|
|
884
|
+
const maxN = Math.max(...rows.map(r=>outs.reduce((a,o)=>a+buckets.get(r)[o],0)));
|
|
885
|
+
const W=520, rowH=34, gap=10, labW=90, barW=W-labW-70;
|
|
886
|
+
const H = rows.length*(rowH+gap)+30;
|
|
887
|
+
const parts = rows.map((r,i)=>{
|
|
888
|
+
const y=i*(rowH+gap)+18, m=buckets.get(r), tot=outs.reduce((a,o)=>a+m[o],0), scale=barW/maxN;
|
|
889
|
+
let x=labW;
|
|
890
|
+
const segs=[]; for(const o of outs){ const w=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>'+r+' · '+label[o]+' '+m[o]+'</title></rect>'); x+=w; }
|
|
891
|
+
return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12" font-weight="600">'+r+'</text>'+
|
|
892
|
+
segs.join('')+
|
|
893
|
+
'<text x="'+(labW+barW+8)+'" y="'+(y+rowH/2+4)+'" fill="var(--ink-2)" font-size="12" font-variant-numeric="tabular-nums">'+tot+'</text>';
|
|
894
|
+
}).join('');
|
|
895
|
+
const legend = outs.filter(o=>rows.some(r=>buckets.get(r)[o]>0)).map(o=>'<g><rect width="10" height="10" fill="'+colors[o]+'"/><text x="14" y="9" fill="var(--ink-2)" font-size="11">'+label[o]+'</text></g>');
|
|
896
|
+
const legendSvg = '<g transform="translate('+labW+','+(H-2)+')">'+legend.map((g,i)=>'<g transform="translate('+(i*90)+',0)">'+g.slice(3,-4)+'</g>').join('')+'</g>';
|
|
897
|
+
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>'});
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
/* Chart 2: horizontal bar of the 18 error 'kinds' from explainError, sorted by count desc */
|
|
901
|
+
function chartFailureCategories(){
|
|
902
|
+
const counts = new Map();
|
|
903
|
+
for(const t of data.tests){ const r=t.results[t.results.length-1], e=r&&r.errors[0]; if(!e||!e.explain) continue; const k=e.explain.label||e.explain.kind||'other'; counts.set(k,(counts.get(k)||0)+1); }
|
|
904
|
+
const rows=[...counts.entries()].sort((a,b)=>b[1]-a[1]);
|
|
905
|
+
if(!rows.length) return null;
|
|
906
|
+
const maxN = rows[0][1];
|
|
907
|
+
const W=520, rowH=22, gap=6, labW=200, barW=W-labW-40;
|
|
908
|
+
const H = rows.length*(rowH+gap)+8;
|
|
909
|
+
const parts = rows.map(([k,n],i)=>{
|
|
910
|
+
const y=i*(rowH+gap)+8, w=Math.max(2, n/maxN*barW);
|
|
911
|
+
return '<text x="0" y="'+(y+rowH/2+4)+'" fill="var(--ink)" font-size="12">'+gEsc(k)+'</text>'+
|
|
912
|
+
'<rect x="'+labW+'" y="'+y+'" width="'+w+'" height="'+rowH+'" fill="var(--fail)" rx="3"><title>'+gEsc(k)+' · '+n+'</title></rect>'+
|
|
913
|
+
'<text x="'+(labW+w+6)+'" y="'+(y+rowH/2+4)+'" fill="var(--ink-2)" font-size="12" font-variant-numeric="tabular-nums">'+n+'</text>';
|
|
914
|
+
}).join('');
|
|
915
|
+
return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="100%">'+parts+'</svg>'});
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/* Chart 3: duration histogram — bars for 5 buckets across all tests */
|
|
919
|
+
function chartDurationHistogram(){
|
|
920
|
+
if(!data.tests.length) return null;
|
|
921
|
+
const buckets = [
|
|
922
|
+
{label:'< 1s', min:0, max:1000},
|
|
923
|
+
{label:'1-3s', min:1000, max:3000},
|
|
924
|
+
{label:'3-10s', min:3000, max:10000},
|
|
925
|
+
{label:'10-30s', min:10000,max:30000},
|
|
926
|
+
{label:'30s+', min:30000,max:Infinity},
|
|
927
|
+
];
|
|
928
|
+
const counts = buckets.map(()=>0);
|
|
929
|
+
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; } } }
|
|
930
|
+
const maxN = Math.max(1,...counts);
|
|
931
|
+
const W=520, colW=80, gap=16, padL=32, padT=24, padB=36, chartH=180;
|
|
932
|
+
const H = chartH+padT+padB;
|
|
933
|
+
const bars = counts.map((n,i)=>{
|
|
934
|
+
const x=padL+i*(colW+gap), h=n/maxN*chartH, y=padT+chartH-h;
|
|
935
|
+
return '<rect x="'+x+'" y="'+y+'" width="'+colW+'" height="'+h+'" fill="var(--accent)" rx="3"><title>'+buckets[i].label+' · '+n+' tests</title></rect>'+
|
|
936
|
+
(n>0?'<text x="'+(x+colW/2)+'" y="'+(y-4)+'" text-anchor="middle" fill="var(--ink)" font-size="12" font-weight="600" font-variant-numeric="tabular-nums">'+n+'</text>':'')+
|
|
937
|
+
'<text x="'+(x+colW/2)+'" y="'+(padT+chartH+22)+'" text-anchor="middle" fill="var(--ink-2)" font-size="12">'+buckets[i].label+'</text>';
|
|
938
|
+
}).join('');
|
|
939
|
+
return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="100%">'+bars+'</svg>'});
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
/* Chart 4: multi-line — pass rate per priority over the last N history entries */
|
|
943
|
+
function chartTrendByPriority(){
|
|
944
|
+
const hist = data.history || [];
|
|
945
|
+
if(hist.length < 2) return null;
|
|
946
|
+
const orders = ['P0','P1','P2','P3','P4'];
|
|
947
|
+
const seenPri = new Set();
|
|
948
|
+
for(const t of data.tests){ const p=(t.meta.priority||'').toString().trim().toUpperCase(); if(orders.includes(p)) seenPri.add(p); }
|
|
949
|
+
const priorities = orders.filter(p=>seenPri.has(p));
|
|
950
|
+
if(!priorities.length) return null;
|
|
951
|
+
const colors = { P0:'#E5405E', P1:'#F59E0B', P2:'#3B82F6', P3:'#8B95A5', P4:'#6B7280' };
|
|
952
|
+
const currentTests = new Map(data.tests.map(t=>[t.key, (t.meta.priority||'').toString().trim().toUpperCase()]));
|
|
953
|
+
const series = priorities.map(pri=>{
|
|
954
|
+
const points = hist.map(e=>{
|
|
955
|
+
let p=0,f=0; for(const [key,val] of Object.entries(e.tests||{})){ if(currentTests.get(key)!==pri) continue; const outcome=val[0]||''; if(outcome==='p') p++; else if(outcome==='f'||outcome==='t'||outcome==='i') f++; }
|
|
956
|
+
const ran = p+f; return ran? Math.round(p/ran*100) : null;
|
|
957
|
+
});
|
|
958
|
+
return { pri, color: colors[pri]||'#888', points };
|
|
959
|
+
}).filter(sr=>sr.points.some(v=>v!==null));
|
|
960
|
+
if(!series.length) return null;
|
|
961
|
+
const W=520, padL=36, padR=24, padT=16, padB=28, chartH=180;
|
|
962
|
+
const H = chartH+padT+padB;
|
|
963
|
+
const N = hist.length;
|
|
964
|
+
const xAt = i => padL + (N===1? 0 : i*((W-padL-padR)/(N-1)));
|
|
965
|
+
const yAt = pct => padT + (100-pct)/100*chartH;
|
|
966
|
+
const grid = [0,25,50,75,100].map(y=>'<line x1="'+padL+'" x2="'+(W-padR)+'" y1="'+yAt(y)+'" y2="'+yAt(y)+'" stroke="var(--line)" stroke-dasharray="2 3"/><text x="'+(padL-6)+'" y="'+(yAt(y)+3)+'" text-anchor="end" font-size="10" fill="var(--ink-3)">'+y+'%</text>').join('');
|
|
967
|
+
const lines = series.map(sr=>{
|
|
968
|
+
// A series may start with (or contain) nulls — runs where this priority
|
|
969
|
+
// had no tests. Each stretch after a gap must restart with M, or the
|
|
970
|
+
// path begins with L and the browser rejects it.
|
|
971
|
+
let pen=false;
|
|
972
|
+
const d = sr.points.map((v,i)=>{ if(v===null){ pen=false; return null; } const cmd=pen?'L':'M'; pen=true; return cmd+xAt(i)+' '+yAt(v); }).filter(Boolean).join(' ');
|
|
973
|
+
const dots = sr.points.map((v,i)=> v===null? '' : '<circle cx="'+xAt(i)+'" cy="'+yAt(v)+'" r="2.5" fill="'+sr.color+'"><title>'+sr.pri+' · '+v+'% at run '+(i+1)+'</title></circle>').join('');
|
|
974
|
+
return '<path d="'+d+'" fill="none" stroke="'+sr.color+'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>'+dots;
|
|
975
|
+
}).join('');
|
|
976
|
+
const legend = series.map((sr,i)=>'<g transform="translate('+(padL+i*70)+','+(H-8)+')"><rect width="10" height="10" y="-9" fill="'+sr.color+'"/><text x="14" y="0" font-size="11" fill="var(--ink-2)">'+sr.pri+'</text></g>').join('');
|
|
977
|
+
return h('div',{class:'gchart-body',html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+W+' '+H+'" width="100%">'+grid+lines+legend+'</svg>'});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/* PNG download: serialize the chart's SVG, draw onto a canvas at 2x, trigger download. */
|
|
981
|
+
function downloadChartPng(chartId, title, btn){
|
|
982
|
+
const card = document.querySelector('[data-chart="'+chartId+'"]');
|
|
983
|
+
const src = card && card.querySelector('svg');
|
|
984
|
+
if(!src) return;
|
|
985
|
+
const label = btn.textContent; btn.textContent = 'Rendering…'; btn.disabled = true;
|
|
986
|
+
const clone = src.cloneNode(true);
|
|
987
|
+
clone.setAttribute('xmlns','http://www.w3.org/2000/svg');
|
|
988
|
+
// Inline computed colors so CSS var() references resolve inside the standalone SVG.
|
|
989
|
+
const cs = getComputedStyle(document.documentElement);
|
|
990
|
+
const varRe = /var\(--([a-z0-9-]+)\)/gi;
|
|
991
|
+
const walk = el => { for(const a of ['fill','stroke']){ const v=el.getAttribute(a); if(v && varRe.test(v)){ el.setAttribute(a, v.replace(varRe,(_,n)=>cs.getPropertyValue('--'+n).trim() || 'currentColor')); } } for(const c of el.children) walk(c); };
|
|
992
|
+
walk(clone);
|
|
993
|
+
// Ensure the SVG has explicit width/height (in px) for correct raster size.
|
|
994
|
+
const vb = clone.viewBox && clone.viewBox.baseVal;
|
|
995
|
+
const w = (vb && vb.width) || 800, hpx = (vb && vb.height) || 400;
|
|
996
|
+
const scale = 2;
|
|
997
|
+
clone.setAttribute('width', w); clone.setAttribute('height', hpx);
|
|
998
|
+
const xml = new XMLSerializer().serializeToString(clone);
|
|
999
|
+
const svgBlob = new Blob([xml], {type:'image/svg+xml;charset=utf-8'});
|
|
1000
|
+
const url = URL.createObjectURL(svgBlob);
|
|
1001
|
+
const img = new Image();
|
|
1002
|
+
img.onload = () => {
|
|
1003
|
+
const canvas = document.createElement('canvas');
|
|
1004
|
+
canvas.width = w*scale; canvas.height = hpx*scale;
|
|
1005
|
+
const ctx = canvas.getContext('2d');
|
|
1006
|
+
ctx.fillStyle = cs.getPropertyValue('--surface').trim() || '#ffffff';
|
|
1007
|
+
ctx.fillRect(0,0,canvas.width,canvas.height);
|
|
1008
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
1009
|
+
URL.revokeObjectURL(url);
|
|
1010
|
+
canvas.toBlob(b => {
|
|
1011
|
+
const a = document.createElement('a');
|
|
1012
|
+
a.href = URL.createObjectURL(b); a.download = 'reporting-labs-'+chartId+'.png'; a.click();
|
|
1013
|
+
URL.revokeObjectURL(a.href);
|
|
1014
|
+
btn.textContent = label; btn.disabled = false;
|
|
1015
|
+
}, 'image/png');
|
|
1016
|
+
};
|
|
1017
|
+
img.onerror = () => { btn.textContent = 'PNG failed'; btn.disabled = false; URL.revokeObjectURL(url); };
|
|
1018
|
+
img.src = url;
|
|
1019
|
+
}
|
|
805
1020
|
function stripe(){
|
|
806
1021
|
const s=data.stats, f=s.failed+s.timedOut+s.interrupted, tot=s.total||1;
|
|
807
1022
|
return h('div',{class:'stripe','aria-hidden':'true'}, [['passed',s.passed],['flaky',s.flaky],['failed',f],['skipped',s.skipped]].filter(x=>x[1]).map(([k,n])=>h('i',{style:'width:'+(n/tot*100)+'%;background:'+colorOf(k)})));
|
|
@@ -1339,8 +1554,8 @@ function renderDetail(t){
|
|
|
1339
1554
|
editorLink(t))),
|
|
1340
1555
|
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:''))));
|
|
1341
1556
|
const metaKeys=Object.keys(t.meta);
|
|
1342
|
-
const linkFor=(k,v)=>{ 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; };
|
|
1343
|
-
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), href=linkFor(k,
|
|
1557
|
+
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; };
|
|
1558
|
+
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); })));
|
|
1344
1559
|
const otherAnn=t.annotations.filter(a=>!DIMS.includes(a.type.toLowerCase()) && !(a.type.toLowerCase() in t.meta));
|
|
1345
1560
|
if(otherAnn.length) d.append(h('h4',{},'Annotations'), h('dl',{class:'kv'}, otherAnn.map(a=>[h('dt',{},a.type),h('dd',{},a.description||'')])));
|
|
1346
1561
|
if(t.results.length>1){
|
package/dist/types.d.ts
CHANGED
|
@@ -77,8 +77,14 @@ export interface ReportingLabsOptions {
|
|
|
77
77
|
/**
|
|
78
78
|
* Turn meta values into links. Key = meta key (story, epic, ticket, issue, jira...), value = URL template with {id}.
|
|
79
79
|
* e.g. { story: 'https://acme.atlassian.net/browse/{id}', epic: 'https://acme.atlassian.net/browse/{id}' }
|
|
80
|
+
*
|
|
81
|
+
* A link that needs more than the displayed value takes an object. Placeholders name the fields of the
|
|
82
|
+
* object passed to meta(); `display` (default '{id}') is what the report shows, the rest only builds the URL:
|
|
83
|
+
* links: { octaneTestCase: { url: 'https://oss.valueedge.com/ui/?p={p}#/entity-navigation?entityType=test&id={id}', display: '{id}' } }
|
|
84
|
+
* meta({ octaneTestCase: { id: '58966', p: '4001/14014' } })
|
|
85
|
+
* The report shows "octaneTestCase 58966", clicking it opens the full URL; p is never shown.
|
|
80
86
|
*/
|
|
81
|
-
links?: Record<string, string>;
|
|
87
|
+
links?: Record<string, string | LinkTemplate>;
|
|
82
88
|
/** Keys (case-insensitive substrings) whose values are masked in test data and API panels. */
|
|
83
89
|
maskKeys?: string[];
|
|
84
90
|
/** Keep a rolling run history next to the report and draw a trend chart. Per-test outcomes are stored too, which powers new-vs-known failures, flaky history and duration regressions. */
|
|
@@ -94,6 +100,13 @@ export interface ReportingLabsOptions {
|
|
|
94
100
|
/** Style Given/When/Then steps as Gherkin and label describe blocks as Features/Scenarios. Default: auto-detect */
|
|
95
101
|
bdd?: boolean;
|
|
96
102
|
}
|
|
103
|
+
/** A link built from several fields of a meta() object. */
|
|
104
|
+
export interface LinkTemplate {
|
|
105
|
+
/** URL with {field} placeholders, e.g. 'https://tms.example.com/{project}/cases/{id}'. */
|
|
106
|
+
url: string;
|
|
107
|
+
/** What the report shows for this meta key, with the same placeholders. Default '{id}'. */
|
|
108
|
+
display?: string;
|
|
109
|
+
}
|
|
97
110
|
/** Lets you type a known value and still accept any string. */
|
|
98
111
|
export type Loose<T extends string> = T | (string & {});
|
|
99
112
|
/**
|
|
@@ -114,14 +127,14 @@ export interface TestMeta extends CustomMeta {
|
|
|
114
127
|
feature?: string;
|
|
115
128
|
/** Epic key, e.g. 'EPIC-18'. Becomes a link when `links.epic` is configured. */
|
|
116
129
|
epic?: string;
|
|
117
|
-
/** User story key, e.g. 'SHOP-250'. Becomes a link when `links.story` is configured. */
|
|
118
|
-
story?: string;
|
|
119
|
-
/** Bug / issue key this test guards against, e.g. 'PROMO-118'. */
|
|
120
|
-
issue?: string;
|
|
130
|
+
/** User story key, e.g. 'SHOP-250', or several. Becomes a link when `links.story` is configured. */
|
|
131
|
+
story?: string | string[];
|
|
132
|
+
/** Bug / issue key this test guards against, e.g. 'PROMO-118', or several. */
|
|
133
|
+
issue?: string | string[];
|
|
121
134
|
/** Test case id in your TCM tool (TestRail, Xray, Zephyr...). */
|
|
122
135
|
testCaseId?: string;
|
|
123
|
-
/** Free-form tags or any other key you like. */
|
|
124
|
-
[key: string]: string | number | undefined;
|
|
136
|
+
/** Free-form tags or any other key you like. An object holds the fields of a multi-parameter link, see `links`. */
|
|
137
|
+
[key: string]: string | number | (string | number)[] | Record<string, string | number> | undefined;
|
|
125
138
|
}
|
|
126
139
|
/** Empty by default; augment it to add typed keys of your own. */
|
|
127
140
|
export interface CustomMeta {
|
|
@@ -237,6 +250,8 @@ export interface TestData {
|
|
|
237
250
|
}[];
|
|
238
251
|
/** extracted dimension values, e.g. { priority: 'P1', severity: 'critical' } */
|
|
239
252
|
meta: Record<string, string>;
|
|
253
|
+
/** Ready-made hrefs for meta keys whose link needed more than the displayed value (object meta + LinkTemplate). */
|
|
254
|
+
links?: Record<string, string>;
|
|
240
255
|
outcome: Status;
|
|
241
256
|
duration: number;
|
|
242
257
|
results: ResultData[];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reporting-labs",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "reportingLabs – beautiful, customizable single-file HTML test reports. One line of config. Playwright adapter included, more runners on the way.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"test-report",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"LICENSE"
|
|
44
44
|
],
|
|
45
45
|
"scripts": {
|
|
46
|
-
"build": "tsc -p tsconfig.json",
|
|
46
|
+
"build": "tsc -p tsconfig.json && node bin/build-template.js",
|
|
47
47
|
"demo": "npm run build && cd example && npx playwright test; cd .. && echo 'open example/reporting-labs/index.html'",
|
|
48
48
|
"prepublishOnly": "npm run build",
|
|
49
49
|
"pack:check": "npm run build && npm pack --dry-run"
|