diff --git a/.claude/skills/js-bundling/SKILL.md b/.claude/skills/js-bundling/SKILL.md index 07e195e6..9ff7d82a 100644 --- a/.claude/skills/js-bundling/SKILL.md +++ b/.claude/skills/js-bundling/SKILL.md @@ -90,7 +90,6 @@ Current third-party libraries: | @sgratzl/chartjs-chart-boxplot | Box plot chart type | | chartjs-chart-matrix | Matrix/heatmap chart type | | GridStack | Drag-and-drop dashboard layout | -| Pako | `pako.inflate()` — decompress embedded data in the browser | | Bootstrap | Modals, layout, admin page UI | | DataTables (3.x, no jQuery) | Tables page, admin page tables | @@ -128,8 +127,8 @@ json.dumps(data).encode("utf-8") # serialize **JavaScript (browser):** ```javascript // js/variables/data.js -const runs = decode_and_decompress("placeholder_runs"); -// atob() → Uint8Array → pako.inflate() → JSON.parse() +await load_data(); // main() awaits this before anything reads runs/suites/tests/... +// atob() → Uint8Array → native DecompressionStream("deflate") → Response.json() ``` -Pako (the JS zlib port) is the only dependency required to decode data, so it must always be loaded before `data.js` runs. +Decoding needs no library: the browser's `DecompressionStream` inflates the zlib payload. It is async, so the data arrays are empty `let` bindings until `load_data()` resolves — never read them at module load time, only from code that runs after `main()` awaited it. diff --git a/docs/advanced-cli-examples.md b/docs/advanced-cli-examples.md index 5a1e9dba..2229a687 100644 --- a/docs/advanced-cli-examples.md +++ b/docs/advanced-cli-examples.md @@ -209,7 +209,6 @@ The following dependencies are bundled with the package and will be used offline | **datatables_css** | CSS | https://cdn.datatables.net/v/bs5/dt-3.0.4/datatables.min.css | Table component styling | | **bootstrap_js** | JS | https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/5.3.8/js/bootstrap.bundle.min.js | Bootstrap interactive components | | **datatables_js** | JS | https://cdn.datatables.net/v/bs5/dt-3.0.4/datatables.min.js | DataTables functionality | -| **pako** | JS | https://cdn.jsdelivr.net/npm/pako@3.0.2/dist/browser/pako_inflate.umd.min.js | Compression/decompression used for smaller dashboard file sizes | ### Behaviour diff --git a/robotframework_dashboard/dependencies.py b/robotframework_dashboard/dependencies.py index 78e69c54..33674502 100644 --- a/robotframework_dashboard/dependencies.py +++ b/robotframework_dashboard/dependencies.py @@ -73,12 +73,6 @@ "local": "dependencies/datatables.js", "admin_page": True, }, - "pako": { - "type": "js", - "cdn": "https://cdn.jsdelivr.net/npm/pako@3.0.2/dist/browser/pako_inflate.umd.min.js", - "local": "dependencies/pako.js", - "admin_page": False, - }, } diff --git a/robotframework_dashboard/dependencies/pako.js b/robotframework_dashboard/dependencies/pako.js deleted file mode 100644 index 59f75756..00000000 --- a/robotframework_dashboard/dependencies/pako.js +++ /dev/null @@ -1,2 +0,0 @@ -(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports):typeof define==`function`&&define.amd?define([`exports`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e.pako={}))})(this,function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=4,n=0,r=1,i=2;function a(e){let t=e.length;for(;--t>=0;)e[t]=0}var o=0,s=1,c=2,l=29,u=256,d=286,f=30,p=19,m=573,h=15,g=16,_=256,v=16,y=17,b=18,x=new Uint8Array([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0]),S=new Uint8Array([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13]);new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]);var C=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),w=512,T=Array(288*2);a(T);var E=Array(f*2);a(E);var D=Array(w);a(D);var O=Array(256);a(O);var k=Array(l);a(k);var A=Array(f);a(A);var j=e=>e<256?D[e]:D[256+(e>>>7)],M=(e,t)=>{e.pending_buf[e.pending++]=t&255,e.pending_buf[e.pending++]=t>>>8&255},N=(e,t,n)=>{e.bi_valid>g-n?(e.bi_buf|=t<>g-e.bi_valid,e.bi_valid+=n-g):(e.bi_buf|=t<{N(e,n[t*2],n[t*2+1])},ee=(e,t)=>{let n=0;do n|=e&1,e>>>=1,n<<=1;while(--t>0);return n>>>1},te=(e,t)=>{let n=t.dyn_tree,r=t.max_code,i=t.stat_desc.static_tree,a=t.stat_desc.has_stree,o=t.stat_desc.extra_bits,s=t.stat_desc.extra_base,c=t.stat_desc.max_length,l,u,d,f,p,g,_=0;for(f=0;f<=h;f++)e.bl_count[f]=0;for(n[e.heap[e.heap_max]*2+1]=0,l=e.heap_max+1;lc&&(f=c,_++),n[u*2+1]=f,!(u>r)&&(e.bl_count[f]++,p=0,u>=s&&(p=o[u-s]),g=n[u*2],e.opt_len+=g*(f+p),a&&(e.static_len+=g*(i[u*2+1]+p)));if(_!==0){do{for(f=c-1;e.bl_count[f]===0;)f--;e.bl_count[f]--,e.bl_count[f+1]+=2,e.bl_count[c]--,_-=2}while(_>0);for(f=c;f!==0;f--)for(u=e.bl_count[f];u!==0;)d=e.heap[--l],!(d>r)&&(n[d*2+1]!==f&&(e.opt_len+=(f-n[d*2+1])*n[d*2],n[d*2+1]=f),u--)}},ne=(e,t,n)=>{let r=Array(16),i=0,a,o;for(a=1;a<=h;a++)i=i+n[a-1]<<1,r[a]=i;for(o=0;o<=t;o++){let t=e[o*2+1];t!==0&&(e[o*2]=ee(r[t]++,t))}},re=e=>{let t;for(t=0;t{e.bi_valid>8?M(e,e.bi_buf):e.bi_valid>0&&(e.pending_buf[e.pending++]=e.bi_buf),e.bi_buf=0,e.bi_valid=0},ae=(e,t,n,r)=>{let i=t*2,a=n*2;return e[i]{let r=e.heap[n],i=n<<1;for(;i<=e.heap_len&&(i{let r,i,a=0,o,s;if(e.sym_next!==0)do r=e.pending_buf[e.sym_buf+a++]&255,r+=(e.pending_buf[e.sym_buf+a++]&255)<<8,i=e.pending_buf[e.sym_buf+a++],r===0?P(e,i,t):(o=O[i],P(e,o+u+1,t),s=x[o],s!==0&&(i-=k[o],N(e,i,s)),r--,o=j(r),P(e,o,n),s=S[o],s!==0&&(r-=A[o],N(e,r,s)));while(a{let n=t.dyn_tree,r=t.stat_desc.static_tree,i=t.stat_desc.has_stree,a=t.stat_desc.elems,o,s,c=-1,l;for(e.heap_len=0,e.heap_max=m,o=0;o>1;o>=1;o--)oe(e,n,o);l=a;do o=e.heap[1],e.heap[1]=e.heap[e.heap_len--],oe(e,n,1),s=e.heap[1],e.heap[--e.heap_max]=o,e.heap[--e.heap_max]=s,n[l*2]=n[o*2]+n[s*2],e.depth[l]=(e.depth[o]>=e.depth[s]?e.depth[o]:e.depth[s])+1,n[o*2+1]=n[s*2+1]=l,e.heap[1]=l++,oe(e,n,1);while(e.heap_len>=2);e.heap[--e.heap_max]=e.heap[1],te(e,t),ne(n,c,e.bl_count)},le=(e,t,n)=>{let r,i=-1,a,o=t[1],s=0,c=7,l=4;for(o===0&&(c=138,l=3),t[(n+1)*2+1]=65535,r=0;r<=n;r++)a=o,o=t[(r+1)*2+1],!(++s{let r,i=-1,a,o=t[1],s=0,c=7,l=4;for(o===0&&(c=138,l=3),r=0;r<=n;r++)if(a=o,o=t[(r+1)*2+1],!(++s{let t;for(le(e,e.dyn_ltree,e.l_desc.max_code),le(e,e.dyn_dtree,e.d_desc.max_code),ce(e,e.bl_desc),t=p-1;t>=3&&e.bl_tree[C[t]*2+1]===0;t--);return e.opt_len+=3*(t+1)+5+5+4,t},fe=(e,t,n,r)=>{let i;for(N(e,t-257,5),N(e,n-1,5),N(e,r-4,4),i=0;i{let t=4093624447,i;for(i=0;i<=31;i++,t>>>=1)if(t&1&&e.dyn_ltree[i*2]!==0)return n;if(e.dyn_ltree[18]!==0||e.dyn_ltree[20]!==0||e.dyn_ltree[26]!==0)return r;for(i=32;i{N(e,(o<<1)+ +!!r,3),ie(e),M(e,n),M(e,~n),n&&e.pending_buf.set(e.window.subarray(t,t+n),e.pending),e.pending+=n},he=(e,n,r,a)=>{let o,l,u=0;e.level>0?(e.strm.data_type===i&&(e.strm.data_type=pe(e)),ce(e,e.l_desc),ce(e,e.d_desc),u=de(e),o=e.opt_len+3+7>>>3,l=e.static_len+3+7>>>3,(l<=o||e.strategy===t)&&(o=l)):o=l=r+5,r+4<=o&&n!==-1?me(e,n,r,a):e.strategy===t||l===o?(N(e,(s<<1)+ +!!a,3),se(e,T,E)):(N(e,(c<<1)+ +!!a,3),fe(e,e.l_desc.max_code+1,e.d_desc.max_code+1,u+1),se(e,e.dyn_ltree,e.dyn_dtree)),re(e),a&&ie(e)},F=(e,t,n)=>(e.pending_buf[e.sym_buf+e.sym_next++]=t,e.pending_buf[e.sym_buf+e.sym_next++]=t>>8,e.pending_buf[e.sym_buf+e.sym_next++]=n,t===0?e.dyn_ltree[n*2]++:(e.matches++,t--,e.dyn_ltree[(O[n]+u+1)*2]++,e.dyn_dtree[j(t)*2]++),e.sym_next===e.sym_end),ge=(e,t,n,r)=>{let i=e&65535|0,a=e>>>16&65535|0,o=0;for(;n!==0;){o=n>2e3?2e3:n,n-=o;do i=i+t[r++]|0,a=a+i|0;while(--o);i%=65521,a%=65521}return i|a<<16|0},_e=new Uint32Array((()=>{let e,t=[];for(var n=0;n<256;n++){e=n;for(var r=0;r<8;r++)e=e&1?3988292384^e>>>1:e>>>1;t[n]=e}return t})()),I=(e,t,n,r)=>{let i=_e,a=r+n;e^=-1;for(let n=r;n>>8^i[(e^t[n])&255];return e^-1},ve={2:`need dictionary`,1:`stream end`,0:``,"-1":`file error`,"-2":`stream error`,"-3":`data error`,"-4":`insufficient memory`,"-5":`buffer error`,"-6":`incompatible version`},L=3,ye=258,R=262,z=1,be=2,xe=3,Se=4,Ce=e=>{let t,n,r,i=e.w_size;t=e.hash_size,r=t;do n=e.head[--r],e.head[r]=n>=i?n-i:0;while(--t);t=i,r=t;do n=e.prev[--r],e.prev[r]=n>=i?n-i:0;while(--t)},we=(e,t,n)=>(t<{let n;if(e.legacy_hash)n=e.ins_h=we(e,e.ins_h,e.window[t+L-1]);else{let r=e.window,i=r[t]|r[t+1]<<8|r[t+2]<<16|r[t+3]<<24;n=e.ins_h=Math.imul(i,66521)+66521>>>16&e.hash_mask}let r=e.prev[t&e.w_mask]=e.head[n];return e.head[n]=t,r},Te=e=>{let t=e.state,n=t.pending;n>e.avail_out&&(n=e.avail_out),n!==0&&(e.output.set(t.pending_buf.subarray(t.pending_out,t.pending_out+n),e.next_out),e.next_out+=n,t.pending_out+=n,e.total_out+=n,e.avail_out-=n,t.pending-=n,t.pending===0&&(t.pending_out=0))},V=(e,t)=>{he(e,e.block_start>=0?e.block_start:-1,e.strstart-e.block_start,t),e.block_start=e.strstart,Te(e.strm)},Ee=(e,t,n,r)=>{let i=e.avail_in;return i>r&&(i=r),i===0?0:(e.avail_in-=i,t.set(e.input.subarray(e.next_in,e.next_in+i),n),e.state.wrap===1?e.adler=ge(e.adler,t,i,n):e.state.wrap===2&&(e.adler=I(e.adler,t,i,n)),e.next_in+=i,e.total_in+=i,i)},De=(e,t)=>{let n=e.max_chain_length,r=e.strstart,i,a,o=e.prev_length,s=e.nice_match,c=e.strstart>e.w_size-R?e.strstart-(e.w_size-R):0,l=e.window,u=e.w_mask,d=e.prev,f=e.strstart+ye,p=l[r+o-1],m=l[r+o];e.prev_length>=e.good_match&&(n>>=2),s>e.lookahead&&(s=e.lookahead);do{if(i=t,l[i+o]!==m||l[i+o-1]!==p||l[i]!==l[r]||l[++i]!==l[r+1])continue;r+=2,i++;do;while(l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&l[++r]===l[++i]&&ro){if(e.match_start=t,o=a,a>=s)break;p=l[r+o-1],m=l[r+o]}}while((t=d[t&u])>c&&--n!==0);return o<=e.lookahead?o:e.lookahead},Oe=e=>{let t=e.w_size,n,r,i;do{if(r=e.window_size-e.lookahead-e.strstart,e.strstart>=t+(t-R)&&(e.window.set(e.window.subarray(t,t+t-r),0),e.match_start-=t,e.strstart-=t,e.block_start-=t,e.insert>e.strstart&&(e.insert=e.strstart),Ce(e),r+=t),e.strm.avail_in===0)break;if(n=Ee(e.strm,e.window,e.strstart+e.lookahead,r),e.lookahead+=n,!e.legacy_hash){if(e.lookahead+e.insert>L)for(i=e.strstart-e.insert;e.insert&&(B(e,i),i++,e.insert--,!(e.lookahead+e.insert<=L)););}else if(e.lookahead+e.insert>=L)for(i=e.strstart-e.insert,e.ins_h=e.window[i],e.ins_h=we(e,e.ins_h,e.window[i+1]);e.insert&&(B(e,i),i++,e.insert--,!(e.lookahead+e.insert{let n=e.pending_buf_size-5>e.w_size?e.w_size:e.pending_buf_size-5,r,i,a,o=0,s=e.strm.avail_in;do{if(r=65535,a=e.bi_valid+42>>3,e.strm.avail_outi+e.strm.avail_in&&(r=i+e.strm.avail_in),r>a&&(r=a),r>8,e.pending_buf[e.pending-2]=~r,e.pending_buf[e.pending-1]=~r>>8,Te(e.strm),i&&(i>r&&(i=r),e.strm.output.set(e.window.subarray(e.block_start,e.block_start+i),e.strm.next_out),e.strm.next_out+=i,e.strm.avail_out-=i,e.strm.total_out+=i,e.block_start+=i,r-=i),r&&(Ee(e.strm,e.strm.output,e.strm.next_out,r),e.strm.next_out+=r,e.strm.avail_out-=r,e.strm.total_out+=r)}while(o===0);return s-=e.strm.avail_in,s&&(s>=e.w_size?(e.matches=2,e.window.set(e.strm.input.subarray(e.strm.next_in-e.w_size,e.strm.next_in),0),e.strstart=e.w_size,e.insert=e.strstart):(e.window_size-e.strstart<=s&&(e.strstart-=e.w_size,e.window.set(e.window.subarray(e.w_size,e.w_size+e.strstart),0),e.matches<2&&e.matches++,e.insert>e.strstart&&(e.insert=e.strstart)),e.window.set(e.strm.input.subarray(e.strm.next_in-s,e.strm.next_in),e.strstart),e.strstart+=s,e.insert+=s>e.w_size-e.insert?e.w_size-e.insert:s),e.block_start=e.strstart),e.high_watera&&e.block_start>=e.w_size&&(e.block_start-=e.w_size,e.strstart-=e.w_size,e.window.set(e.window.subarray(e.w_size,e.w_size+e.strstart),0),e.matches<2&&e.matches++,a+=e.w_size,e.insert>e.strstart&&(e.insert=e.strstart)),a>e.strm.avail_in&&(a=e.strm.avail_in),a&&(Ee(e.strm,e.window,e.strstart,a),e.strstart+=a,e.insert+=a>e.w_size-e.insert?e.w_size-e.insert:a),e.high_water>3,a=e.pending_buf_size-a>65535?65535:e.pending_buf_size-a,n=a>e.w_size?e.w_size:a,i=e.strstart-e.block_start,(i>=n||(i||t===4)&&t!==0&&e.strm.avail_in===0&&i<=a)&&(r=i>a?a:i,o=+(t===4&&e.strm.avail_in===0&&r===i),me(e,e.block_start,r,o),e.block_start+=r,Te(e.strm)),o?xe:z)},Ae=(e,t)=>{let n,r;for(;;){if(e.lookahead=L&&(n=B(e,e.strstart)),n!==0&&e.strstart-n<=e.w_size-R&&(e.match_length=De(e,n)),e.match_length>=L)if(r=F(e,e.strstart-e.match_start,e.match_length-L),e.lookahead-=e.match_length,e.match_length<=e.max_lazy_match&&e.lookahead>=L){e.match_length--;do e.strstart++,n=B(e,e.strstart);while(--e.match_length!==0);e.strstart++}else e.strstart+=e.match_length,e.match_length=0,e.legacy_hash&&(e.ins_h=e.window[e.strstart],e.ins_h=we(e,e.ins_h,e.window[e.strstart+1]));else r=F(e,0,e.window[e.strstart]),e.lookahead--,e.strstart++;if(r&&(V(e,!1),e.strm.avail_out===0))return z}return e.insert=e.strstart{let n,r,i;for(;;){if(e.lookahead=L&&(n=B(e,e.strstart)),e.prev_length=e.match_length,e.prev_match=e.match_start,e.match_length=L-1,n!==0&&e.prev_length4096)&&(e.match_length=L-1)),e.prev_length>=L&&e.match_length<=e.prev_length){i=e.strstart+e.lookahead-L,r=F(e,e.strstart-1-e.prev_match,e.prev_length-L),e.lookahead-=e.prev_length-1,e.prev_length-=2;do++e.strstart<=i&&(n=B(e,e.strstart));while(--e.prev_length!==0);if(e.match_available=0,e.match_length=L-1,e.strstart++,r&&(V(e,!1),e.strm.avail_out===0))return z}else if(e.match_available){if(r=F(e,0,e.window[e.strstart-1]),r&&V(e,!1),e.strstart++,e.lookahead--,e.strm.avail_out===0)return z}else e.match_available=1,e.strstart++,e.lookahead--}return e.match_available&&(r=F(e,0,e.window[e.strstart-1]),e.match_available=0),e.insert=e.strstart>>24,f>>>=y,p-=y,y=v>>>16&255,y===0)T[i++]=v&65535;else if(y&16){b=v&65535,y&=15,y&&(p>>=y,p-=y),p<15&&(f+=w[n++]<>>24,f>>>=y,p-=y,y=v>>>16&255,y&16){if(x=v&65535,y&=15,ps){e.msg=`invalid distance too far back`,E.mode=W;break top}if(f>>>=y,p-=y,y=i-a,x>y){if(y=x-y,y>l&&E.sane){e.msg=`invalid distance too far back`,E.mode=W;break top}if(S=0,C=d,u===0){if(S+=c-y,y2;)T[i++]=C[S++],T[i++]=C[S++],T[i++]=C[S++],b-=3;b&&(T[i++]=C[S++],b>1&&(T[i++]=C[S++]))}else{S=i-x;do T[i++]=T[S++],T[i++]=T[S++],T[i++]=T[S++],b-=3;while(b>2);b&&(T[i++]=T[S++],b>1&&(T[i++]=T[S++]))}}else if(y&64){e.msg=`invalid distance code`,E.mode=W;break top}else{v=h[(v&65535)+(f&(1<>3,n-=b,p-=b<<3,f&=(1<{let c=s.bits,l=0,u=0,d=0,f=0,p=0,m=0,h=0,g=0,_=0,v=0,y,b,x,S,C,w=null,T,E=new Uint16Array(16),D=new Uint16Array(16),O=null,k,A,j;for(l=0;l<=G;l++)E[l]=0;for(u=0;u=1&&E[f]===0;f--);if(p>f&&(p=f),f===0)return i[a++]=20971520,i[a++]=20971520,s.bits=1,0;for(d=1;d0&&(e===Fe||f!==1))return-1;for(D[1]=0,l=1;lNe||e===Le&&_>Pe)return 1;for(;;){k=l-h,o[u]+1=T?(A=O[o[u]-T],j=w[o[u]-T]):(A=96,j=0),y=1<>h)+b]=k<<24|A<<16|j|0;while(b!==0);for(y=1<>=1;if(y===0?v=0:(v&=y-1,v+=y),u++,--E[l]===0){if(l===f)break;l=t[n+o[u]]}if(l>p&&(v&S)!==x){for(h===0&&(h=p),C+=d,m=l-h,g=1<Ne||e===Le&&_>Pe)return 1;x=v&S,i[x]=p<<24|m<<16|C-a|0}}return v!==0&&(i[C+v]=l-h<<24|4194304),s.bits=p,0},He=0,Ue=1,We=2,q=16180,Ge=16181,Ke=16182,qe=16183,Je=16184,Ye=16185,Xe=16186,Ze=16187,Qe=16188,$e=16189,J=16190,Y=16191,et=16192,tt=16193,nt=16194,rt=16195,it=16196,at=16197,ot=16198,st=16199,ct=16200,lt=16201,ut=16202,dt=16203,ft=16204,pt=16205,mt=16206,ht=16207,gt=16208,X=16209,_t=16210,vt=16211,yt=852,bt=592,xt=e=>(e>>>24&255)+(e>>>8&65280)+((e&65280)<<8)+((e&255)<<24),St=class{constructor(){this.strm=null,this.mode=0,this.last=!1,this.wrap=0,this.havedict=!1,this.flags=0,this.dmax=0,this.check=0,this.total=0,this.head=null,this.wbits=0,this.wsize=0,this.whave=0,this.wnext=0,this.window=null,this.hold=0,this.bits=0,this.length=0,this.offset=0,this.extra=0,this.lencode=null,this.distcode=null,this.lenbits=0,this.distbits=0,this.ncode=0,this.nlen=0,this.ndist=0,this.have=0,this.next=null,this.lens=new Uint16Array(320),this.work=new Uint16Array(288),this.lendyn=null,this.distdyn=null,this.sane=0,this.back=0,this.was=0}},Z=e=>{if(!e)return 1;let t=e.state;return+(!t||t.strm!==e||t.modevt)},Ct=e=>{if(Z(e))return-2;let t=e.state;return e.total_in=e.total_out=t.total=0,e.msg=``,t.wrap&&(e.adler=t.wrap&1),t.mode=q,t.last=0,t.havedict=0,t.flags=-1,t.dmax=32768,t.head=null,t.hold=0,t.bits=0,t.lencode=t.lendyn=new Int32Array(yt),t.distcode=t.distdyn=new Int32Array(bt),t.sane=1,t.back=-1,0},wt=e=>{if(Z(e))return-2;let t=e.state;return t.wsize=0,t.whave=0,t.wnext=0,Ct(e)},Tt=(e,t)=>{let n;if(Z(e))return-2;let r=e.state;return t<0?(n=0,t=-t):(n=(t>>4)+5,t<48&&(t&=15)),t&&(t<8||t>15)?-2:(r.window!==null&&r.wbits!==t&&(r.window=null),r.wrap=n,r.wbits=t,wt(e))},Et=(e,t)=>{if(!e)return-2;let n=new St;e.state=n,n.strm=e,n.window=null,n.mode=q;let r=Tt(e,t);return r!==0&&(e.state=null),r},Dt=!0,Ot,kt,At=e=>{if(Dt){Ot=new Int32Array(512),kt=new Int32Array(32);let t=0;for(;t<144;)e.lens[t++]=8;for(;t<256;)e.lens[t++]=9;for(;t<280;)e.lens[t++]=7;for(;t<288;)e.lens[t++]=8;for(K(Ue,e.lens,0,288,Ot,0,e.work,{bits:9}),t=0;t<32;)e.lens[t++]=5;K(We,e.lens,0,32,kt,0,e.work,{bits:5}),Dt=!1}e.lencode=Ot,e.lenbits=9,e.distcode=kt,e.distbits=5},jt=(e,t,n,r)=>{let i,a=e.state;return a.window===null&&(a.window=new Uint8Array(1<=a.wsize?(a.window.set(t.subarray(n-a.wsize,n),0),a.wnext=0,a.whave=a.wsize):(i=a.wsize-a.wnext,i>r&&(i=r),a.window.set(t.subarray(n-r,n-r+i),a.wnext),r-=i,r?(a.window.set(t.subarray(n-r,n),0),a.wnext=r,a.whave=a.wsize):(a.wnext+=i,a.wnext===a.wsize&&(a.wnext=0),a.whave{let n,r,i,a,o,s,c,l,u,d,f,p,m,h,g=0,_,v,y,b,x,S,C,w,T=new Uint8Array(4),E,D,O=new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]);if(Z(e)||!e.output||!e.input&&e.avail_in!==0)return-2;n=e.state,n.mode===Y&&(n.mode=et),o=e.next_out,i=e.output,c=e.avail_out,a=e.next_in,r=e.input,s=e.avail_in,l=n.hold,u=n.bits,d=s,f=c,w=0;inf_leave:for(;;)switch(n.mode){case q:if(n.wrap===0){n.mode=et;break}for(;u<16;){if(s===0)break inf_leave;s--,l+=r[a++]<>>8&255,n.check=I(n.check,T,2,0),l=0,u=0,n.mode=Ge;break}if(n.head&&(n.head.done=!1),!(n.wrap&1)||(((l&255)<<8)+(l>>8))%31){e.msg=`incorrect header check`,n.mode=X;break}if((l&15)!=8){e.msg=`unknown compression method`,n.mode=X;break}if(l>>>=4,u-=4,C=(l&15)+8,n.wbits===0&&(n.wbits=C),C>15||C>n.wbits){e.msg=`invalid window size`,n.mode=X;break}n.dmax=1<>8&1),n.flags&512&&n.wrap&4&&(T[0]=l&255,T[1]=l>>>8&255,n.check=I(n.check,T,2,0)),l=0,u=0,n.mode=Ke;case Ke:for(;u<32;){if(s===0)break inf_leave;s--,l+=r[a++]<>>0),n.flags&512&&n.wrap&4&&(T[0]=l&255,T[1]=l>>>8&255,T[2]=l>>>16&255,T[3]=l>>>24&255,n.check=I(n.check,T,4,0)),l=0,u=0,n.mode=qe;case qe:for(;u<16;){if(s===0)break inf_leave;s--,l+=r[a++]<>8),n.flags&512&&n.wrap&4&&(T[0]=l&255,T[1]=l>>>8&255,n.check=I(n.check,T,2,0)),l=0,u=0,n.mode=Je;case Je:if(n.flags&1024){for(;u<16;){if(s===0)break inf_leave;s--,l+=r[a++]<>>8&255,n.check=I(n.check,T,2,0)),l=0,u=0}else n.head&&(n.head.extra=null);n.mode=Ye;case Ye:if(n.flags&1024&&(p=n.length,p>s&&(p=s),p&&(n.head&&(C=n.head.extra_len-n.length,n.head.extra||(n.head.extra=new Uint8Array(n.head.extra_len)),n.head.extra.set(r.subarray(a,a+p),C)),n.flags&512&&n.wrap&4&&(n.check=I(n.check,r,p,a)),s-=p,a+=p,n.length-=p),n.length))break inf_leave;n.length=0,n.mode=Xe;case Xe:if(n.flags&2048){if(s===0)break inf_leave;p=0;do C=r[a+p++],n.head&&C&&n.length<65536&&(n.head.name+=String.fromCharCode(C));while(C&&p>9&1,n.head.done=!0),e.adler=n.check=0,n.mode=Y;break;case $e:for(;u<32;){if(s===0)break inf_leave;s--,l+=r[a++]<>>=u&7,u-=u&7,n.mode=mt;break}for(;u<3;){if(s===0)break inf_leave;s--,l+=r[a++]<>>=1,--u,l&3){case 0:n.mode=tt;break;case 1:if(At(n),n.mode=st,t===6){l>>>=2,u-=2;break inf_leave}break;case 2:n.mode=it;break;case 3:e.msg=`invalid block type`,n.mode=X}l>>>=2,u-=2;break;case tt:for(l>>>=u&7,u-=u&7;u<32;){if(s===0)break inf_leave;s--,l+=r[a++]<>>16^65535)){e.msg=`invalid stored block lengths`,n.mode=X;break}if(n.length=l&65535,l=0,u=0,n.mode=nt,t===6)break inf_leave;case nt:n.mode=rt;case rt:if(p=n.length,p){if(p>s&&(p=s),p>c&&(p=c),p===0)break inf_leave;i.set(r.subarray(a,a+p),o),s-=p,a+=p,c-=p,o+=p,n.length-=p;break}n.mode=Y;break;case it:for(;u<14;){if(s===0)break inf_leave;s--,l+=r[a++]<>>=5,u-=5,n.ndist=(l&31)+1,l>>>=5,u-=5,n.ncode=(l&15)+4,l>>>=4,u-=4,n.nlen>286||n.ndist>30){e.msg=`too many length or distance symbols`,n.mode=X;break}n.have=0,n.mode=at;case at:for(;n.have>>=3,u-=3}for(;n.have<19;)n.lens[O[n.have++]]=0;if(n.lencode=n.lendyn,n.lenbits=7,E={bits:n.lenbits},w=K(He,n.lens,0,19,n.lencode,0,n.work,E),n.lenbits=E.bits,w){e.msg=`invalid code lengths set`,n.mode=X;break}n.have=0,n.mode=ot;case ot:for(;n.have>>24,v=g>>>16&255,y=g&65535,!(_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>>=_,u-=_,n.lens[n.have++]=y;else{if(y===16){for(D=_+2;u>>=_,u-=_,n.have===0){e.msg=`invalid bit length repeat`,n.mode=X;break}C=n.lens[n.have-1],p=3+(l&3),l>>>=2,u-=2}else if(y===17){for(D=_+3;u>>=_,u-=_,C=0,p=3+(l&7),l>>>=3,u-=3}else{for(D=_+7;u>>=_,u-=_,C=0,p=11+(l&127),l>>>=7,u-=7}if(n.have+p>n.nlen+n.ndist){e.msg=`invalid bit length repeat`,n.mode=X;break}for(;p--;)n.lens[n.have++]=C}}if(n.mode===X)break;if(n.lens[256]===0){e.msg=`invalid code -- missing end-of-block`,n.mode=X;break}if(n.lenbits=9,E={bits:n.lenbits},w=K(Ue,n.lens,0,n.nlen,n.lencode,0,n.work,E),n.lenbits=E.bits,w){e.msg=`invalid literal/lengths set`,n.mode=X;break}if(n.distbits=6,n.distcode=n.distdyn,E={bits:n.distbits},w=K(We,n.lens,n.nlen,n.ndist,n.distcode,0,n.work,E),n.distbits=E.bits,w){e.msg=`invalid distances set`,n.mode=X;break}if(n.mode=st,t===6)break inf_leave;case st:n.mode=ct;case ct:if(s>=6&&c>=258){e.next_out=o,e.avail_out=c,e.next_in=a,e.avail_in=s,n.hold=l,n.bits=u,Me(e,f),o=e.next_out,i=e.output,c=e.avail_out,a=e.next_in,r=e.input,s=e.avail_in,l=n.hold,u=n.bits,n.mode===Y&&(n.back=-1);break}for(n.back=0;g=n.lencode[l&(1<>>24,v=g>>>16&255,y=g&65535,!(_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>b)],_=g>>>24,v=g>>>16&255,y=g&65535,!(b+_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>>=b,u-=b,n.back+=b}if(l>>>=_,u-=_,n.back+=_,n.length=y,v===0){n.mode=pt;break}if(v&32){n.back=-1,n.mode=Y;break}if(v&64){e.msg=`invalid literal/length code`,n.mode=X;break}n.extra=v&15,n.mode=lt;case lt:if(n.extra){for(D=n.extra;u>>=n.extra,u-=n.extra,n.back+=n.extra}n.was=n.length,n.mode=ut;case ut:for(;g=n.distcode[l&(1<>>24,v=g>>>16&255,y=g&65535,!(_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>b)],_=g>>>24,v=g>>>16&255,y=g&65535,!(b+_<=u);){if(s===0)break inf_leave;s--,l+=r[a++]<>>=b,u-=b,n.back+=b}if(l>>>=_,u-=_,n.back+=_,v&64){e.msg=`invalid distance code`,n.mode=X;break}n.offset=y,n.extra=v&15,n.mode=dt;case dt:if(n.extra){for(D=n.extra;u>>=n.extra,u-=n.extra,n.back+=n.extra}if(n.offset>n.dmax){e.msg=`invalid distance too far back`,n.mode=X;break}n.mode=ft;case ft:if(c===0)break inf_leave;if(p=f-c,n.offset>p){if(p=n.offset-p,p>n.whave&&n.sane){e.msg=`invalid distance too far back`,n.mode=X;break}p>n.wnext?(p-=n.wnext,m=n.wsize-p):m=n.wnext-p,p>n.length&&(p=n.length),h=n.window}else h=i,m=o-n.offset,p=n.length;p>c&&(p=c),c-=p,n.length-=p;do i[o++]=h[m++];while(--p);n.length===0&&(n.mode=ct);break;case pt:if(c===0)break inf_leave;i[o++]=n.length,c--,n.mode=ct;break;case mt:if(n.wrap){for(;u<32;){if(s===0)break inf_leave;s--,l|=r[a++]<{if(Z(e))return-2;let t=e.state;return t.window&&(t.window=null),e.state=null,0},Pt=(e,t)=>{let n=t.length,r,i,a;return Z(e)||(r=e.state,r.wrap!==0&&r.mode!==J)?-2:r.mode===J&&(i=1,i=ge(i,t,n,0),i!==r.check)?-3:(a=jt(e,t,n,n),a?(r.mode=_t,-4):(r.havedict=1,0))},Ft=class{constructor(){this.input=null,this.next_in=0,this.avail_in=0,this.total_in=0,this.output=null,this.next_out=0,this.avail_out=0,this.total_out=0,this.msg=``,this.state=null,this.data_type=2,this.adler=0}},It=e=>{let t=new Uint8Array(e.reduce((e,t)=>e+t.length,0)),n=0;for(let r of e)t.set(r,n),n+=r.length;return t};function Q(e){"@babel/helpers - typeof";return Q=typeof Symbol==`function`&&typeof Symbol.iterator==`symbol`?function(e){return typeof e}:function(e){return e&&typeof Symbol==`function`&&e.constructor===Symbol&&e!==Symbol.prototype?`symbol`:typeof e},Q(e)}function Lt(e,t){if(Q(e)!=`object`||!e)return e;var n=e[Symbol.toPrimitive];if(n!==void 0){var r=n.call(e,t||`default`);if(Q(r)!=`object`)return r;throw TypeError(`@@toPrimitive must return a primitive value.`)}return(t===`string`?String:Number)(e)}function Rt(e){var t=Lt(e,`string`);return Q(t)==`symbol`?t:t+``}function $(e,t,n){return(t=Rt(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function zt(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function Bt(e){for(var t=1;t=0&&t.windowBits<16&&(t.windowBits=-t.windowBits,t.windowBits===0&&(t.windowBits=-15)),t.windowBits>=0&&t.windowBits<16&&!e.windowBits&&(t.windowBits+=32),t.windowBits>15&&t.windowBits<48&&(t.windowBits&15||(t.windowBits|=15)),this.err=0,this.msg=``,this.ended=!1,this.started=!1,this.chunks=[],this.result=new Uint8Array,this.strm=new Ft,this.strm.avail_out=0;let n=Et(this.strm,t.windowBits);if(n!==0)throw Error(ve[n]);Vt.call(t.dictionary)===`[object ArrayBuffer]`&&(t.dictionary=new Uint8Array(t.dictionary));let r=t.dictionary;if(t.raw&&r.length&&(n=Pt(this.strm,r),n!==0))throw Error(ve[n])}push(e,t=!1){let n=this.strm,r=this.options.chunkSize,i,a,o;if(this.ended)return this.err===0;for(a=typeof t==`number`?t:t===!0?4:0,Vt.call(e)===`[object ArrayBuffer]`?n.input=new Uint8Array(e):n.input=e,n.next_in=0,n.avail_in=n.input.length,this.started||(this.started=!0,this.onStart(n));;){if(n.avail_out===0&&(n.output=new Uint8Array(r),n.next_out=0,n.avail_out=r),i=Mt(n,a),i===2){let e=this.options.dictionary;e.length&&(i=Pt(n,e),i===0?i=Mt(n,a):i===-3&&(i=2))}for(;n.avail_in>0&&i===1&&n.state.wrap&2&&n.state.flags!==0&&n.input[n.next_in]!==0;)wt(n),i=Mt(n,a);if(i===-2||i===-3||i===2||i===-4)break;if(o=n.avail_out,n.next_out&&(n.avail_out===0||i===1||a>0)&&(this.onData(n.output.length===n.next_out?n.output:n.output.subarray(0,n.next_out)),n.avail_out=0,n.next_out=0),!((i===0||i===-5)&&o===0)){if(i===1){i=Nt(this.strm);break}if(n.avail_in===0){if(a===4){i=Nt(this.strm),i===0&&(i=-5);break}return!0}}}return this.err=i,this.msg=n.msg||ve[i],this.ended=!0,this.onEnd(i),i===0}onStart(e){}onData(e){this.chunks.push(e)}onEnd(e){e===0&&(this.result=It(this.chunks)),this.chunks=[]}};function Wt(e,t={}){let n=new Ut(t);if(n.push(e,!0),n.err)throw Error(n.msg);let r=n.result;return t.toText?new TextDecoder().decode(r):r}function Gt(e,t={}){return Wt(e,Bt(Bt({},t),{},{raw:!0}))}e.Inflate=Ut,e.inflate=Wt,e.ungzip=Wt,e.inflateRaw=Gt}); -//# sourceMappingURL=pako_inflate.umd.min.js.map \ No newline at end of file diff --git a/robotframework_dashboard/js/filter/availability.js b/robotframework_dashboard/js/filter/availability.js index 69e87d35..23cc6e00 100644 --- a/robotframework_dashboard/js/filter/availability.js +++ b/robotframework_dashboard/js/filter/availability.js @@ -1,6 +1,5 @@ import { settings } from '../variables/settings.js'; -import { runs, suites } from '../variables/data.js'; -import { apply_custom_filter_dimension, apply_date_filter, apply_metadata_filter, apply_project_version_filter, apply_run_name_filter, apply_runtag_filter, build_date_range, collect_custom_filter_dimensions, convert_timezone, get_custom_filter_value, get_hidden_custom_filters, get_project_version_value, remove_milliseconds, remove_timezones } from './pipeline.js'; +import { apply_custom_filter_dimension, apply_date_filter, apply_metadata_filter, apply_project_version_filter, apply_run_name_filter, apply_runtag_filter, build_date_range, collect_custom_filter_dimensions, get_custom_filter_value, get_hidden_custom_filters, get_project_version_value, get_transformed_data } from './pipeline.js'; import { get_metadata_options } from './modal_options.js'; import { apply_suite_path_run_filter } from './suite_path.js'; import { capture_current_filters } from './profiles.js'; @@ -11,36 +10,6 @@ import { capture_current_filters } from './profiles.js'; // The counts follow the usual faceted-search rule: the count of an option in filter X is // computed with every filter except X applied, so selecting a value in X never makes the other // values of X disappear. The amount filter ("last X runs") is not a category and is left out. -let filterBaseRunsCache = { key: null, runs: null, suites: null }; - -// key of the settings that change run_start representation, and with it which runs a date -// range matches and which suites belong to which run -function get_filter_base_cache_key() { - return `${settings.show.milliseconds}|${settings.show.convertTimezone}|${settings.show.timezones}`; -} - -function apply_run_start_transformations(data) { - return remove_timezones(convert_timezone(remove_milliseconds(data))); -} - -// all runs with the same run_start transformations the filter pipeline applies, so the -// availability computation compares the same timestamps as the real filters do -function get_filter_base_runs() { - const key = get_filter_base_cache_key(); - if (filterBaseRunsCache.key !== key) { - filterBaseRunsCache = { key: key, runs: apply_run_start_transformations(runs), suites: null }; - } - return filterBaseRunsCache.runs; -} - -// suites are only needed while a suite path is selected, so they are transformed on demand -function get_filter_base_suites() { - get_filter_base_runs(); - if (filterBaseRunsCache.suites === null) { - filterBaseRunsCache.suites = apply_run_start_transformations(suites); - } - return filterBaseRunsCache.suites; -} // turn the profile object of capture_current_filters() into the selection shape the apply_* // functions take @@ -85,7 +54,7 @@ function apply_filters_except(runList, selections, facet, dimName = null, suiteL result = apply_date_filter(result, selections.dateRange.from, selections.dateRange.to); } if (selections.suitePath && selections.suitePath !== "All") { - result = apply_suite_path_run_filter(result, selections.suitePath, suiteList ?? get_filter_base_suites()); + result = apply_suite_path_run_filter(result, selections.suitePath, suiteList ?? get_transformed_data("suites")); } return result; } @@ -164,7 +133,7 @@ function compute_filter_option_availability(runList, selections, suiteList = nul // amount filter is left out for the same reason it is left out of the option counts. function get_runs_for_date_histogram() { const selections = normalize_filter_selections(capture_current_filters()); - return apply_filters_except(get_filter_base_runs(), selections, "dates"); + return apply_filters_except(get_transformed_data("runs"), selections, "dates"); } // add or update the "(X)" count of one filter option row. The count is a sibling of the @@ -237,7 +206,7 @@ function refresh_filter_option_availability() { return; } const selections = normalize_filter_selections(capture_current_filters()); - const availability = compute_filter_option_availability(get_filter_base_runs(), selections); + const availability = compute_filter_option_availability(get_transformed_data("runs"), selections); apply_availability_to_select(document.getElementById("runs"), availability.runs); apply_availability_to_select(document.getElementById("metadata"), availability.metadata); apply_availability_to_checkbox_list(document.getElementById("runTag"), availability.runTags); diff --git a/robotframework_dashboard/js/filter/pipeline.js b/robotframework_dashboard/js/filter/pipeline.js index ef78ed4c..6030f657 100644 --- a/robotframework_dashboard/js/filter/pipeline.js +++ b/robotframework_dashboard/js/filter/pipeline.js @@ -8,11 +8,26 @@ import { filter_runs_by_suite_path, filter_suite_path_data } from './suite_path. // Sort an array of run objects by wall-clock run_start (timezone offset stripped), // ensuring correct chronological order when timestamps span mixed timezone offsets. function sort_wall_clock(data) { - return [...data].sort((a, b) => { - const ak = strip_tz_suffix(a.run_start); - const bk = strip_tz_suffix(b.run_start); - return ak < bk ? -1 : ak > bk ? 1 : 0; - }); + return data + .map((item, index) => ({ item, index, key: strip_tz_suffix(item.run_start) })) + .sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : a.index - b.index) + .map(entry => entry.item); +} + +// the run_start transformations only depend on these settings, so they are cached per combination +// instead of copying every row on each filter apply; the filter availability uses the same copies +let transformedDataCache = { key: null, data: {} }; + +function get_transformed_data(name) { + const key = `${settings.show.milliseconds}|${settings.show.convertTimezone}|${settings.show.timezones}`; + if (transformedDataCache.key !== key) { + transformedDataCache = { key: key, data: {} }; + } + if (!(name in transformedDataCache.data)) { + const sourceData = { runs, suites, tests, keywords, exceptions }[name]; + transformedDataCache.data[name] = remove_timezones(convert_timezone(remove_milliseconds(sourceData))); + } + return transformedDataCache.data[name]; } const dashboardPages = ["overview", "dashboard", "compare", "tables"]; @@ -38,23 +53,11 @@ function apply_custom_filter_visibility() { // function updates the data in the graphs whenever filters are updated function setup_filtered_data_and_filters() { apply_custom_filter_visibility(); - filteredRuns = remove_milliseconds(runs) - filteredSuites = remove_milliseconds(suites) - filteredTests = remove_milliseconds(tests) - filteredKeywords = remove_milliseconds(keywords) - filteredExceptions = remove_milliseconds(exceptions) - // convert timezones if enabled (must run before remove_timezones so the offset is still present) - filteredRuns = convert_timezone(filteredRuns); - filteredSuites = convert_timezone(filteredSuites); - filteredTests = convert_timezone(filteredTests); - filteredKeywords = convert_timezone(filteredKeywords); - filteredExceptions = convert_timezone(filteredExceptions); - // remove timezone display if disabled - filteredRuns = remove_timezones(filteredRuns); - filteredSuites = remove_timezones(filteredSuites); - filteredTests = remove_timezones(filteredTests); - filteredKeywords = remove_timezones(filteredKeywords); - filteredExceptions = remove_timezones(filteredExceptions); + filteredRuns = get_transformed_data("runs"); + filteredSuites = get_transformed_data("suites"); + filteredTests = get_transformed_data("tests"); + filteredKeywords = get_transformed_data("keywords"); + filteredExceptions = get_transformed_data("exceptions"); // determine filteredRuns with all run-level filters (suite path + amount last) filteredRuns = filter_runs(filteredRuns); filteredRuns = filter_runtags(filteredRuns); @@ -437,8 +440,8 @@ function filter_metadata(filteredRuns) { // function to filter suites/tests/keywords based on the already filtered runs function filter_data(data) { - const validRunStarts = filteredRuns.map(v => v.run_start); - let filteredData = data.filter(v => validRunStarts.includes(v.run_start)); + const validRunStarts = new Set(filteredRuns.map(v => v.run_start)); + let filteredData = data.filter(v => validRunStarts.has(v.run_start)); if (filteredData.length > 0 && "owner" in filteredData[0]) { const libraries = settings.libraries || {}; filteredData = filteredData.filter(item => { @@ -470,8 +473,10 @@ export { get_hidden_custom_filters, get_project_version_value, get_run_start_date, + get_transformed_data, parse_custom_filters, remove_milliseconds, remove_timezones, setup_filtered_data_and_filters, + sort_wall_clock, }; diff --git a/robotframework_dashboard/js/filter/section_selects.js b/robotframework_dashboard/js/filter/section_selects.js index cb05e182..c6b9b7a3 100644 --- a/robotframework_dashboard/js/filter/section_selects.js +++ b/robotframework_dashboard/js/filter/section_selects.js @@ -103,20 +103,19 @@ function setup_tests_in_select() { const testTagsSelect = document.getElementById("testTagsSelect").value; const testSelect = document.getElementById("testSelect"); testSelect.innerHTML = ""; - const testNames = filteredTests.reduce((names, test) => { + const testNames = new Set(); + for (const test of filteredTests) { + if (testNames.has(test.name)) continue; const isInSuite = settings.switch.suitePathsTestSection ? test.full_name.includes(`${suiteSelectTests}.${test.name}`) || suiteSelectTests === "All" : test.full_name.includes(`.${suiteSelectTests}.${test.name}`) || suiteSelectTests === "All" const hasTag = testTagsSelect === "All" || test.tags.includes(testTagsSelect); - - if (isInSuite && hasTag && !names.includes(test.name)) { - names.push(test.name); + if (isInSuite && hasTag) { + testNames.add(test.name); } - - return names; - }, []); + } testSelect.options.add(new Option("All", "All")); - testNames.sort().forEach(testName => testSelect.options.add(new Option(testName, testName))); + [...testNames].sort().forEach(testName => testSelect.options.add(new Option(testName, testName))); } // function to update the available testtags to select in the filters diff --git a/robotframework_dashboard/js/graph_creation/overview.js b/robotframework_dashboard/js/graph_creation/overview.js index e25f957a..67a7b4ed 100644 --- a/robotframework_dashboard/js/graph_creation/overview.js +++ b/robotframework_dashboard/js/graph_creation/overview.js @@ -449,15 +449,13 @@ function create_project_cards_container(projectName, projectRuns, percent = null } const container = document.getElementById(`${projectName}RunCardsContainer`); + destroy_overview_donuts(container); container.innerHTML = ''; const projectRunsToShow = projectRuns.slice().reverse(); - // create cards and charts for each run card + // create cards and charts for each run card, create_project_run_card also creates the donut projectRunsToShow.forEach((run, idx) => { const runNumber = projectRunsToShow.length - idx; - const createdRunCardId = create_project_run_card(run, projectName, idx, runNumber, passRate, percent, durations, false); - const createdRunCard = document.getElementById(createdRunCardId); - container.appendChild(createdRunCard); - create_overview_run_donut(run, idx, projectName); + create_project_run_card(run, projectName, idx, runNumber, passRate, percent, durations, false); }); } @@ -467,6 +465,7 @@ function create_overview_latest_graphs(preFilteredRuns = null) { if (!orderEl) return; const order = orderEl.value; const overviewCardsContainer = document.getElementById("overviewLatestRunCardsContainer"); + destroy_overview_donuts(overviewCardsContainer); overviewCardsContainer.innerHTML = ''; const allProjects = { ...projects_by_name, ...projects_by_tag }; const durationsByProject = {}; @@ -527,6 +526,7 @@ function create_overview_latest_graphs(preFilteredRuns = null) { function create_overview_total_graphs(preFilteredRuns = null) { const overviewCardsContainer = document.getElementById("overviewTotalRunCardsContainer"); if (!overviewCardsContainer) return; + destroy_overview_donuts(overviewCardsContainer); overviewCardsContainer.innerHTML = ''; const allProjects = { ...projects_by_name, ...projects_by_tag }; const durationsByProject = {}; @@ -621,6 +621,7 @@ function create_project_run_card(run, projectName, runIndex, runNumber, passRate ) const existingRunCard = document.getElementById(`${projectNameForId}Card${runIndex}`); if (existingRunCard) { + destroy_overview_donuts(existingRunCard); // preserves listeners of element existingRunCard.replaceWith(document.createRange().createContextualFragment(projectRunCardHTML)); } else { @@ -660,7 +661,7 @@ function create_overview_run_donut(run, chartElementPostfix, projectName) { ); return; } - if (el.chartInstance) el.chartInstance.destroy(); + destroy_overview_donut(el); const chartData = { labels: [], datasets: [{ @@ -684,7 +685,44 @@ function create_overview_run_donut(run, chartElementPostfix, projectName) { const config = get_graph_config('donut', chartData, 'Run Status'); delete config.options.plugins.datalabels; config.options.plugins.legend.display = false; - el.chartInstance = new Chart(el, config); + // the run cards can hold hundreds of donuts of which only a few are on screen, so a donut + // is only created once its card comes near the viewport + el.pendingChartConfig = config; + get_lazy_donut_observer().observe(el); +} + +let lazyDonutObserver = null; + +function get_lazy_donut_observer() { + if (!lazyDonutObserver) { + lazyDonutObserver = new IntersectionObserver(entries => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const el = entry.target; + lazyDonutObserver.unobserve(el); + if (el.pendingChartConfig) { + el.chartInstance = new Chart(el, el.pendingChartConfig); + delete el.pendingChartConfig; + } + } + }, { rootMargin: "500px" }); + } + return lazyDonutObserver; +} + +function destroy_overview_donut(el) { + lazyDonutObserver?.unobserve(el); + delete el.pendingChartConfig; + if (el.chartInstance) { + el.chartInstance.destroy(); + delete el.chartInstance; + } +} + +// Chart.js keeps a reference to every chart until it is destroyed, so the donuts have to be +// destroyed before their cards are removed from the page +function destroy_overview_donuts(container) { + container.querySelectorAll(".overview-canvas canvas").forEach(destroy_overview_donut); } function update_overview_latest_heading() { diff --git a/robotframework_dashboard/js/graph_creation/suite.js b/robotframework_dashboard/js/graph_creation/suite.js index 00ce3fa7..313b2143 100644 --- a/robotframework_dashboard/js/graph_creation/suite.js +++ b/robotframework_dashboard/js/graph_creation/suite.js @@ -3,7 +3,7 @@ import { get_statistics_graph_data } from '../graph_data/statistics.js'; import { get_duration_graph_data } from '../graph_data/duration.js'; import { get_graph_config } from '../graph_data/graph_config.js'; import { build_tooltip_meta, lookup_tooltip_meta, format_status } from '../graph_data/tooltip_helpers.js'; -import { exclude_from_suite_data } from '../graph_data/helpers.js'; +import { get_suite_data_exclusion } from '../graph_data/helpers.js'; import { setup_suites_in_suite_select } from '../filter/section_selects.js'; import { format_duration } from '../common.js'; import { dataLabelConfig } from '../variables/chartconfig.js'; @@ -120,7 +120,7 @@ function _build_suite_statistics_config() { const callbackData = data[1] const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; const isCombined = suiteSelectSuites === "All Suites Combined"; - const relevantSuites = filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + const relevantSuites = get_relevant_suites(); const tooltipMeta = build_tooltip_meta(relevantSuites, 'elapsed_s', isCombined); var config; if (settings.graphTypes.suiteStatisticsGraphType == "line") { @@ -179,7 +179,7 @@ function _build_suite_duration_config() { const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; const isCombined = suiteSelectSuites === "All Suites Combined"; // Filter suites the same way get_duration_graph_data does, so tooltip meta matches - const relevantSuites = filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + const relevantSuites = get_relevant_suites(); const tooltipMeta = build_tooltip_meta(relevantSuites, 'elapsed_s', isCombined); var config; if (settings.graphTypes.suiteDurationGraphType == "bar") { @@ -197,9 +197,14 @@ function _build_suite_duration_config() { return config; } +function get_relevant_suites() { + const exclude_from_suite_data = get_suite_data_exclusion("suite"); + return filteredSuites.filter(suite => !exclude_from_suite_data(suite)); +} + function _get_suite_most_filtered_data() { if (!settings.switch.sectionFiltersApplySuite) return filteredSuites; - return filteredSuites.filter(s => !exclude_from_suite_data("suite", s)); + return get_relevant_suites(); } function _build_suite_most_failed_config() { diff --git a/robotframework_dashboard/js/graph_creation/tables.js b/robotframework_dashboard/js/graph_creation/tables.js index 2f6e44d0..dff796a3 100644 --- a/robotframework_dashboard/js/graph_creation/tables.js +++ b/robotframework_dashboard/js/graph_creation/tables.js @@ -38,31 +38,37 @@ function _get_exception_table_data() { ]); } +// DataTables detects the type of a column by checking every cell on every update, which is slow +// for large tables, so the columns get the type it would detect. Only the version is left to the +// detection, because it can be numeric or free text depending on the project. +function run_start_column() { return { title: "run", type: "date" }; } +function number_column(title) { return { title, type: "num" }; } +function text_column(title, type = "string") { return { title, type }; } const runColumns = [ - { title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" }, - { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, - { title: "start_time" }, { title: "version" }, { title: "tags" }, { title: "alias" }, { title: "metadata" }, - { title: "custom_filters" }, + run_start_column(), text_column("full_name"), text_column("name"), number_column("total"), + number_column("passed"), number_column("failed"), number_column("skipped"), number_column("elapsed_s"), + text_column("start_time"), { title: "version" }, text_column("tags"), text_column("alias"), text_column("metadata"), + text_column("custom_filters"), ]; const suiteColumns = [ - { title: "run" }, { title: "full_name" }, { title: "name" }, { title: "total" }, - { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, - { title: "start_time" }, { title: "alias" }, { title: "id" }, + run_start_column(), text_column("full_name"), text_column("name"), number_column("total"), + number_column("passed"), number_column("failed"), number_column("skipped"), number_column("elapsed_s"), + text_column("start_time"), text_column("alias"), text_column("id"), ]; const testColumns = [ - { title: "run" }, { title: "full_name" }, { title: "name" }, - { title: "passed" }, { title: "failed" }, { title: "skipped" }, { title: "elapsed_s" }, - { title: "start_time" }, { title: "message" }, { title: "tags" }, { title: "alias" }, { title: "id" }, - { title: "attempts" }, + run_start_column(), text_column("full_name"), text_column("name"), + number_column("passed"), number_column("failed"), number_column("skipped"), number_column("elapsed_s"), + text_column("start_time"), text_column("message", "html"), text_column("tags"), text_column("alias"), text_column("id"), + text_column("attempts", "string-utf8"), ]; const keywordColumns = [ - { title: "run" }, { title: "name" }, { title: "passed" }, { title: "failed" }, - { title: "skipped" }, { title: "times_run" }, { title: "total_execution_time" }, - { title: "average_execution_time" }, { title: "min_execution_time" }, - { title: "max_execution_time" }, { title: "alias" }, { title: "owner" }, + run_start_column(), text_column("name"), number_column("passed"), number_column("failed"), + number_column("skipped"), number_column("times_run"), number_column("total_execution_time"), + number_column("average_execution_time"), number_column("min_execution_time"), + number_column("max_execution_time"), text_column("alias"), text_column("owner"), ]; const exceptionColumns = [ - { title: "run" }, { title: "message" }, { title: "amount" }, { title: "alias" }, + run_start_column(), text_column("message"), number_column("amount"), text_column("alias"), ]; function create_data_table(tableId, columns, getDataFn) { diff --git a/robotframework_dashboard/js/graph_data/duration.js b/robotframework_dashboard/js/graph_data/duration.js index ec461927..79fb1b10 100644 --- a/robotframework_dashboard/js/graph_data/duration.js +++ b/robotframework_dashboard/js/graph_data/duration.js @@ -2,7 +2,7 @@ import { settings, get_run_label } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; import { barConfig, lineConfig } from "../variables/chartconfig.js"; import { compareRunIds } from "../variables/graphs.js"; -import { exclude_from_suite_data } from "./helpers.js"; +import { get_suite_data_exclusion } from "./helpers.js"; import { strip_tz_suffix } from "../common.js"; // function to prepare the data in the correct format for duration graphs @@ -14,6 +14,7 @@ function get_duration_graph_data(dataType, graphType, objectDataAttribute, filte const keywordSelect = document.getElementById("keywordSelect").value; const useLibraryNames = settings?.switch?.useLibraryNames === true; const limit = inFullscreen && inFullscreenGraph.includes("Duration") ? 100 : 30; + const exclude_from_suite_data = get_suite_data_exclusion(dataType); const should_include = (value) => { if (dataType === "keyword") { const keywordKey = useLibraryNames && value.owner @@ -22,7 +23,7 @@ function get_duration_graph_data(dataType, graphType, objectDataAttribute, filte if (keywordKey !== keywordSelect) return false; } - if (exclude_from_suite_data(dataType, value)) return false; + if (exclude_from_suite_data(value)) return false; if (settings.switch.suitePathsTestSection) { if (dataType === "test" && suiteSelectTests !== "All" && value.full_name !== `${suiteSelectTests}.${value.name}`) { diff --git a/robotframework_dashboard/js/graph_data/failed.js b/robotframework_dashboard/js/graph_data/failed.js index 06348e53..8146ebb5 100644 --- a/robotframework_dashboard/js/graph_data/failed.js +++ b/robotframework_dashboard/js/graph_data/failed.js @@ -1,6 +1,6 @@ import { settings, get_run_label } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; -import { convert_timeline_data, parse_test_attempts } from "./helpers.js"; +import { convert_timeline_data, group_timeline_values, parse_test_attempts } from "./helpers.js"; import { failedConfig, rerunBorderColor, rerunBorderWidth } from "../variables/chartconfig.js"; import { strip_tz_suffix } from "../common.js"; @@ -93,13 +93,10 @@ function get_most_failed_data(dataType, graphType, filteredData, recent) { let datasets = []; let runAxis = 0; const pointMeta = {}; + const groups = group_timeline_values(filteredData, labels, value => value.failed > 0 ? [getTestKey(value, dataType)] : []); for (const runStart of runStarts) { for (const label of labels) { - const foundValues = filteredData.filter(value => - getTestKey(value, dataType) === label && - value.run_start === runStart && - value.failed > 0 - ); + const foundValues = groups.get(label).get(runStart) ?? []; if (foundValues.length > 0) { const value = foundValues[0]; // tests re-executed with robot --rerunfailed (rebot --merge history) get the rerun border diff --git a/robotframework_dashboard/js/graph_data/flaky.js b/robotframework_dashboard/js/graph_data/flaky.js index 1198224d..10efaa75 100644 --- a/robotframework_dashboard/js/graph_data/flaky.js +++ b/robotframework_dashboard/js/graph_data/flaky.js @@ -1,6 +1,6 @@ import { settings, get_run_label } from "../variables/settings.js"; import { passedConfig, failedConfig, skippedConfig, rerunBorderColor, rerunBorderWidth } from "../variables/chartconfig.js"; -import { convert_timeline_data, parse_test_attempts, resolve_test_status, count_attempt_flips } from "./helpers.js"; +import { convert_timeline_data, group_timeline_values, parse_test_attempts, resolve_test_status, count_attempt_flips } from "./helpers.js"; import { strip_tz_suffix } from "../common.js"; // function to prepare the data in the correct format for (recent) most flaky test graph @@ -77,36 +77,28 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, }; return [graphData, data]; } else if (graphType == "timeline") { - var [labels, runStarts, count, run_labels] = [[], [], 0, []]; + var [labels, runStartsSet, count, runLabelsSet] = [[], new Set(), 0, new Set()]; for (const key in sortedData) { if (count == limit) { break; } labels.push(sortedData[key][0]); for (const runStart of sortedData[key][1].run_starts) { - if (!runStarts.includes(runStart)) { - runStarts.push(runStart); - } + runStartsSet.add(runStart); } count += 1; } var datasets = []; var runAxis = 0; const pointMeta = {}; - runStarts = runStarts.sort((a, b) => new Date(strip_tz_suffix(a)).getTime() - new Date(strip_tz_suffix(b)).getTime()) + var runStarts = [...runStartsSet].sort((a, b) => new Date(strip_tz_suffix(a)).getTime() - new Date(strip_tz_suffix(b)).getTime()) + const groups = group_timeline_values(filteredData, labels, value => [settings.switch.suitePathsTestSection ? value.full_name : value.name]); for (const runStart of runStarts) { for (const label of labels) { - var foundValues = []; - for (value of filteredData) { - const compareKey = settings.switch.suitePathsTestSection ? value.full_name : value.name; - if (compareKey == label && value.run_start == runStart) { - foundValues.push(value); - const runLabel = get_run_label(value); - if (!run_labels.includes(runLabel)) { run_labels.push(runLabel) } - } - } + const foundValues = groups.get(label).get(runStart) ?? []; + foundValues.forEach(value => runLabelsSet.add(get_run_label(value))); if (foundValues.length > 0) { - var value = foundValues[0]; + const value = foundValues[0]; const [status, attempts] = status_of(value); const statusName = status === "passed" ? "PASS" : status === "failed" ? "FAIL" : "SKIP"; pointMeta[`${label}::${runAxis}`] = { @@ -127,7 +119,7 @@ function get_most_flaky_data(dataType, graphType, filteredData, ignore, recent, } runAxis += 1; } - if (settings.show.aliases === "alias" || settings.show.aliases === "run_name") { runStarts = run_labels } + if (settings.show.aliases === "alias" || settings.show.aliases === "run_name") { runStarts = [...runLabelsSet] } datasets = convert_timeline_data(datasets) var graphData = { labels: labels, diff --git a/robotframework_dashboard/js/graph_data/helpers.js b/robotframework_dashboard/js/graph_data/helpers.js index a60257fa..21df83ac 100644 --- a/robotframework_dashboard/js/graph_data/helpers.js +++ b/robotframework_dashboard/js/graph_data/helpers.js @@ -3,43 +3,58 @@ import { barConfig } from "../variables/chartconfig.js"; import { inFullscreen} from "../variables/globals.js"; // helper function to more easily use the logic of filtering suite graph data based on the selected filters -// returns true if the value should be excluded, false if it should be included -function exclude_from_suite_data(dataType, value) { - if (dataType !== "suite") return false; +// returns a function that returns true if a value should be excluded, false if it should be included +// the selected filters are read once here instead of once per value +function get_suite_data_exclusion(dataType) { + if (dataType !== "suite") return () => false; const suiteSelectSuites = document.getElementById("suiteSelectSuites").value; - const suiteSelectSuitesOptions = [...document.getElementById("suiteSelectSuites").options].map(o => o.value); + const suiteSelectSuitesOptions = new Set([...document.getElementById("suiteSelectSuites").options].map(o => o.value)); const suiteFolder = document.getElementById("suiteFolder").innerText; const isFolderAll = suiteFolder === "All"; const isSuiteAll = suiteSelectSuites === "All Suites Separate" || suiteSelectSuites === "All Suites Combined"; const usingSuitePaths = settings.switch.suitePathsSuiteSection; const folderMatches = (val) => val.full_name === suiteFolder || val.full_name.startsWith(`${suiteFolder}.`); - const suiteNameMatches = (val) => suiteSelectSuitesOptions.includes(val.name); - const fullNameMatches = (val) => suiteSelectSuitesOptions.includes(val.full_name); + const suiteNameMatches = (val) => suiteSelectSuitesOptions.has(val.name); + const fullNameMatches = (val) => suiteSelectSuitesOptions.has(val.full_name); + const selectedSuiteMismatch = (val) => usingSuitePaths + ? val.full_name !== suiteSelectSuites + : val.name !== suiteSelectSuites; if (isFolderAll && isSuiteAll) { // All folders, all suites: include all - return false; + return () => false; } if (isFolderAll && !isSuiteAll) { // All folders, specific suite - return usingSuitePaths - ? value.full_name !== suiteSelectSuites - : value.name !== suiteSelectSuites; + return selectedSuiteMismatch; } if (!isFolderAll && isSuiteAll) { // Specific folder, all suites - if (!folderMatches(value)) return true; - - return usingSuitePaths - ? !fullNameMatches(value) - : !suiteNameMatches(value); + return (value) => { + if (!folderMatches(value)) return true; + return usingSuitePaths + ? !fullNameMatches(value) + : !suiteNameMatches(value); + }; } // Specific folder, specific suite - if (!folderMatches(value)) return true; - return usingSuitePaths - ? value.full_name !== suiteSelectSuites - : value.name !== suiteSelectSuites; + return (value) => !folderMatches(value) || selectedSuiteMismatch(value); +} + +// function to group timeline rows by label and run_start in one pass +// returns Map(label -> Map(run_start -> [rows])), rows whose labels are not in labels are skipped +function group_timeline_values(data, labels, get_labels) { + const groups = new Map(labels.map(label => [label, new Map()])); + for (const value of data) { + for (const label of get_labels(value)) { + const byRunStart = groups.get(label); + if (!byRunStart) continue; + if (!byRunStart.has(value.run_start)) byRunStart.set(value.run_start, []); + byRunStart.get(value.run_start).push(value); + } + } + return groups; } // function to update the height of the test statistics graph and enable scrolling @@ -170,7 +185,8 @@ function format_attempt_lines(attempts, maxMessageLength = 80) { } export { - exclude_from_suite_data, + get_suite_data_exclusion, + group_timeline_values, update_height, convert_timeline_data, parse_test_attempts, diff --git a/robotframework_dashboard/js/graph_data/messages.js b/robotframework_dashboard/js/graph_data/messages.js index 3e764d49..7e5b3c4c 100644 --- a/robotframework_dashboard/js/graph_data/messages.js +++ b/robotframework_dashboard/js/graph_data/messages.js @@ -2,7 +2,7 @@ import { settings, get_run_label } from "../variables/settings.js"; import { inFullscreen, inFullscreenGraph } from "../variables/globals.js"; import { failedConfig, rerunBorderColor, rerunBorderWidth } from "../variables/chartconfig.js"; import { message_config } from "../variables/data.js"; -import { convert_timeline_data, parse_test_attempts } from "./helpers.js"; +import { convert_timeline_data, group_timeline_values, parse_test_attempts } from "./helpers.js"; import { strip_tz_suffix } from "../common.js"; // function to prepare the data in the correct format for messages graphs @@ -22,13 +22,18 @@ function get_messages_data(dataType, graphType, filteredData) { run_names.get(value.message).push(value.run_name); } } - // If there is a message config use that to merge the data - if (!message_config.includes("placeholder_message_config")) { - function matches_message_config(str, rule) { - rule = rule.replace(/\$\{.*?\}/g, "*") // match any ${something} string - var escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); // escape the test messages to prevent regex mismatches - return new RegExp("^" + rule.split("*").map(escapeRegex).join(".*") + "$").test(str); + const useMessageConfig = !message_config.includes("placeholder_message_config"); + const messageConfigRegexes = new Map(); + function matches_message_config(str, rule) { + if (!messageConfigRegexes.has(rule)) { + const wildcardRule = rule.replace(/\$\{.*?\}/g, "*") // match any ${something} string + const escapeRegex = (str) => str.replace(/([.*+?^=!:${}()|\[\]\/\\])/g, "\\$1"); // escape the test messages to prevent regex mismatches + messageConfigRegexes.set(rule, new RegExp("^" + wildcardRule.split("*").map(escapeRegex).join(".*") + "$")); } + return messageConfigRegexes.get(rule).test(str); + } + // If there is a message config use that to merge the data + if (useMessageConfig) { for (const config of message_config) { for (const [message, runStarts] of data) { if (message == config) { continue } @@ -43,8 +48,7 @@ function get_messages_data(dataType, graphType, filteredData) { } for (const [message, runStarts] of data) { - arrayWithDuplicates = data.get(message) - data.set(message, [...new Set(arrayWithDuplicates)]) + data.set(message, [...new Set(runStarts)]) } } const limit = inFullscreen && inFullscreenGraph.includes("Messages") ? 50 : 10; @@ -94,14 +98,13 @@ function get_messages_data(dataType, graphType, filteredData) { var datasets = []; let runAxis = 0; const pointMeta = {}; - function check_label(message, label) { - return !message_config.includes("placeholder_message_config") - ? matches_message_config(message, label) - : message === label; - } + // with a message config one message can match more than one label + const groups = group_timeline_values(filteredData, labels, value => useMessageConfig + ? labels.filter(label => matches_message_config(value.message, label)) + : [value.message]); for (const runStart of runStarts) { for (const label of labels) { - const foundValues = filteredData.filter(value => check_label(value.message, label) && value.run_start === runStart); + const foundValues = groups.get(label).get(runStart) ?? []; if (foundValues.length > 0) { const value = foundValues[0]; // tests re-executed with robot --rerunfailed (rebot --merge history) get the rerun border diff --git a/robotframework_dashboard/js/graph_data/statistics.js b/robotframework_dashboard/js/graph_data/statistics.js index 67027039..a9184665 100644 --- a/robotframework_dashboard/js/graph_data/statistics.js +++ b/robotframework_dashboard/js/graph_data/statistics.js @@ -14,7 +14,7 @@ import { rerunBorderWidth } from "../variables/chartconfig.js"; import { settings, get_run_label } from "../variables/settings.js"; -import { convert_timeline_data, exclude_from_suite_data, parse_test_attempts, resolve_test_status } from "./helpers.js"; +import { convert_timeline_data, get_suite_data_exclusion, parse_test_attempts, resolve_test_status } from "./helpers.js"; import { compareRunIds } from "../variables/graphs.js"; // function to prepare the data in the correct format for statistics graphs @@ -33,8 +33,9 @@ function get_statistics_graph_data(dataType, graphType, filteredData) { runNames.push(value.run_name ?? value.name); names.push(value.name); }; + const exclude_from_suite_data = get_suite_data_exclusion(dataType); for (const value of filteredData) { - if (exclude_from_suite_data(dataType, value)) continue; + if (exclude_from_suite_data(value)) continue; if (dataType === "keyword") { const keywordKey = useLibraryNames && value.owner ? `${value.owner}.${value.name}` diff --git a/robotframework_dashboard/js/graph_data/tooltip_helpers.js b/robotframework_dashboard/js/graph_data/tooltip_helpers.js index 545b6a14..781c3a0d 100644 --- a/robotframework_dashboard/js/graph_data/tooltip_helpers.js +++ b/robotframework_dashboard/js/graph_data/tooltip_helpers.js @@ -4,6 +4,8 @@ function build_tooltip_meta(filteredData, durationField = 'elapsed_s', aggregate = false) { const byLabel = {}; const byTime = {}; + // many rows share a run_start, so every run_start is parsed only once + const timeKeys = new Map(); for (const item of filteredData) { const elapsed = parseFloat(item[durationField]) || 0; const p = item.passed || 0; @@ -11,7 +13,10 @@ function build_tooltip_meta(filteredData, durationField = 'elapsed_s', aggregate const s = item.skipped || 0; const msg = item.message || ''; const keys = [item.run_start, item.run_alias]; - const timeKey = new Date(item.run_start.replace(" ", "T")).getTime(); + if (!timeKeys.has(item.run_start)) { + timeKeys.set(item.run_start, new Date(item.run_start.replace(" ", "T")).getTime()); + } + const timeKey = timeKeys.get(item.run_start); const meta = { elapsed_s: elapsed, passed: p, failed: f, skipped: s, message: msg }; for (const key of keys) { if (aggregate && byLabel[key]) { diff --git a/robotframework_dashboard/js/main.js b/robotframework_dashboard/js/main.js index 72f415cc..e1df835c 100644 --- a/robotframework_dashboard/js/main.js +++ b/robotframework_dashboard/js/main.js @@ -6,10 +6,12 @@ import { setup_collapsables } from "./eventlisteners/collapsables.js"; import { setup_filter_modal } from "./eventlisteners/filter_modal.js"; import { setup_settings_modal } from "./eventlisteners/settings_modal.js"; import { setup_menu, setup_navbar_overflow } from "./menu.js"; +import { load_data } from "./variables/data.js"; // function that triggers all functions that should be executed when the dashboard is loaded first // in the correct order! -function main() { +async function main() { + await load_data(); setup_local_storage(); setup_database_stats(); setup_dashboard_section_layout_buttons(); diff --git a/robotframework_dashboard/js/variables/data.js b/robotframework_dashboard/js/variables/data.js index 3de99b69..82d15f64 100644 --- a/robotframework_dashboard/js/variables/data.js +++ b/robotframework_dashboard/js/variables/data.js @@ -1,15 +1,31 @@ -// prepare input data -const runs = decode_and_decompress("placeholder_runs"); -const suites = decode_and_decompress("placeholder_suites"); -const tests = decode_and_decompress("placeholder_tests"); -const keywords = decode_and_decompress("placeholder_keywords"); -const exceptions = decode_and_decompress("placeholder_exceptions"); +// the input data, filled by load_data() which main() awaits before anything reads it +let runs = []; +let suites = []; +let tests = []; +let keywords = []; +let exceptions = []; -function decode_and_decompress(base64Str) { +async function load_data() { + [runs, suites, tests, keywords, exceptions] = await Promise.all([ + decode_and_decompress("placeholder_runs"), + decode_and_decompress("placeholder_suites"), + decode_and_decompress("placeholder_tests"), + decode_and_decompress("placeholder_keywords"), + decode_and_decompress("placeholder_exceptions"), + ]); +} + +// the payloads are zlib compressed JSON, inflated with the native DecompressionStream +async function decode_and_decompress(base64Str) { if (base64Str.includes("placeholder_")) return []; - const compressedData = Uint8Array.from(atob(base64Str), c => c.charCodeAt(0)); - const decompressedData = pako.inflate(compressedData, { toText: true }); - return JSON.parse(decompressedData); + // a plain loop is much faster than Uint8Array.from with a map callback on large payloads + const binaryStr = atob(base64Str); + const compressedData = new Uint8Array(binaryStr.length); + for (let i = 0; i < binaryStr.length; i++) { + compressedData[i] = binaryStr.charCodeAt(i); + } + const stream = new Blob([compressedData]).stream().pipeThrough(new DecompressionStream("deflate")); + return new Response(stream).json(); } var unified_dashboard_title = '"placeholder_dashboard_title"' @@ -24,6 +40,7 @@ const no_auto_update = "placeholder_no_autoupdate" if (!message_config.includes("placeholder_message_config")) { message_config = JSON.parse(message_config) } export { + load_data, runs, suites, tests, diff --git a/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt b/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt index 6dffbe48..c7e47585 100644 --- a/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt +++ b/robotframework_dashboard/licenses/THIRD_PARTY_LICENSES.txt @@ -229,31 +229,3 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -======================================== -Pako -https://github.com/nodeca/pako -Version: 3.0.2 -License: MIT -======================================== -(The MIT License) - -Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/tests/javascript/filter.test.js b/tests/javascript/filter.test.js index aaee9864..ad6b6788 100644 --- a/tests/javascript/filter.test.js +++ b/tests/javascript/filter.test.js @@ -5,13 +5,15 @@ vi.mock('@js/variables/data.js', () => import('./mocks/data.js')); vi.mock('@js/variables/graphs.js', () => import('./mocks/graphs.js')); import { strip_tz_suffix, get_run_projects } from '@js/common.js'; -import { strip_tz_suffix } from '@js/common.js'; import { dashboardPages, get_active_page, get_hidden_custom_filters, + get_transformed_data, parse_custom_filters, + sort_wall_clock, } from '@js/filter/pipeline.js'; +import { runs } from '@js/variables/data.js'; import { settings } from '@js/variables/settings.js'; // settings is module level state, so it is restored between tests @@ -27,15 +29,6 @@ beforeEach(() => { // so here we test the reusable logic patterns (sorting, data transformations) // that the filter functions rely on. -// Reimplementation of sort_wall_clock from filter.js for direct testing -function sort_wall_clock(data) { - return [...data].sort((a, b) => { - const ak = strip_tz_suffix(a.run_start); - const bk = strip_tz_suffix(b.run_start); - return ak < bk ? -1 : ak > bk ? 1 : 0; - }); -} - describe('filter.js pure logic', () => { describe('sort_wall_clock logic', () => { @@ -87,6 +80,47 @@ describe('filter.js pure logic', () => { sort_wall_clock(data); expect(data).toEqual(original); }); + + it('keeps the original order of items with the same wall-clock time', () => { + const data = [ + { run_start: '2025-01-15 10:00:00', name: 'b' }, + { run_start: '2025-01-15 09:00:00', name: 'c' }, + { run_start: '2025-01-15 10:00:00+02:00', name: 'a' }, + ]; + expect(sort_wall_clock(data).map(item => item.name)).toEqual(['c', 'b', 'a']); + }); + }); + + describe('get_transformed_data', () => { + beforeEach(() => { + runs.length = 0; + runs.push({ run_start: '2025-01-15 09:05:03.123+02:00', name: 'run1' }); + settings.show.milliseconds = true; + settings.show.timezones = true; + settings.show.convertTimezone = false; + }); + + it('applies the run_start transformations of the settings', () => { + settings.show.milliseconds = false; + settings.show.timezones = false; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03'); + expect(runs[0].run_start).toBe('2025-01-15 09:05:03.123+02:00'); + }); + + it('reuses the transformed data while the settings stay the same', () => { + settings.show.milliseconds = false; + const first = get_transformed_data('runs'); + expect(get_transformed_data('runs')).toBe(first); + }); + + it('transforms again when one of the settings changes', () => { + settings.show.milliseconds = false; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03+02:00'); + settings.show.timezones = false; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03'); + settings.show.milliseconds = true; + expect(get_transformed_data('runs')[0].run_start).toBe('2025-01-15 09:05:03.123'); + }); }); describe('remove_milliseconds logic', () => { @@ -174,8 +208,8 @@ describe('filter.js pure logic', () => { { run_start: '2025-01-15 10:00:00', name: 'suite2' }, { run_start: '2025-01-15 11:00:00', name: 'suite3' }, ]; - const validRunStarts = filteredRuns.map(v => v.run_start); - const result = data.filter(v => validRunStarts.includes(v.run_start)); + const validRunStarts = new Set(filteredRuns.map(v => v.run_start)); + const result = data.filter(v => validRunStarts.has(v.run_start)); expect(result).toHaveLength(2); expect(result[0].name).toBe('suite1'); expect(result[1].name).toBe('suite2'); @@ -186,8 +220,8 @@ describe('filter.js pure logic', () => { const data = [ { run_start: '2025-01-15 09:00:00', name: 'suite1' }, ]; - const validRunStarts = filteredRuns.map(v => v.run_start); - const result = data.filter(v => validRunStarts.includes(v.run_start)); + const validRunStarts = new Set(filteredRuns.map(v => v.run_start)); + const result = data.filter(v => validRunStarts.has(v.run_start)); expect(result).toHaveLength(0); }); }); diff --git a/tests/javascript/graph_data/helpers.test.js b/tests/javascript/graph_data/helpers.test.js index 3cd0211c..466e002f 100644 --- a/tests/javascript/graph_data/helpers.test.js +++ b/tests/javascript/graph_data/helpers.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; vi.mock('@js/variables/settings.js', () => ({ settings: { @@ -17,7 +17,8 @@ vi.mock('@js/variables/globals.js', () => ({ inFullscreenGraph: '', })); -import { convert_timeline_data, parse_test_attempts, resolve_test_status, format_attempt_lines, count_attempt_flips, get_rerun_summary } from '@js/graph_data/helpers.js'; +import { convert_timeline_data, parse_test_attempts, resolve_test_status, format_attempt_lines, count_attempt_flips, get_rerun_summary, group_timeline_values, get_suite_data_exclusion } from '@js/graph_data/helpers.js'; +import { settings } from '@js/variables/settings.js'; describe('convert_timeline_data', () => { @@ -189,3 +190,93 @@ describe('get_rerun_summary', () => { }); }); + +describe('group_timeline_values', () => { + const rows = [ + { name: 'A', run_start: 'r1', id: 1 }, + { name: 'B', run_start: 'r1', id: 2 }, + { name: 'A', run_start: 'r2', id: 3 }, + { name: 'A', run_start: 'r1', id: 4 }, + { name: 'C', run_start: 'r1', id: 5 }, + ]; + + it('groups the rows by label and run_start in their original order', () => { + const groups = group_timeline_values(rows, ['A', 'B'], row => [row.name]); + expect(groups.get('A').get('r1').map(row => row.id)).toEqual([1, 4]); + expect(groups.get('A').get('r2').map(row => row.id)).toEqual([3]); + expect(groups.get('B').get('r1').map(row => row.id)).toEqual([2]); + expect(groups.get('B').get('r2')).toBeUndefined(); + }); + + it('only keeps the given labels', () => { + const groups = group_timeline_values(rows, ['B'], row => [row.name]); + expect([...groups.keys()]).toEqual(['B']); + }); + + it('adds a row to every label it belongs to and skips rows without a label', () => { + const groups = group_timeline_values(rows, ['A', 'any'], row => row.name === 'A' ? ['A', 'any'] : []); + expect(groups.get('any').get('r1').map(row => row.id)).toEqual([1, 4]); + expect(groups.get('A').get('r1').map(row => row.id)).toEqual([1, 4]); + }); +}); + +describe('get_suite_data_exclusion', () => { + const suites = [ + { name: 'Login', full_name: 'Web.Login' }, + { name: 'Cart', full_name: 'Web.Cart' }, + { name: 'Login', full_name: 'Api.Login' }, + ]; + let reads = 0; + function stub_document(suiteSelect, options, folder) { + reads = 0; + globalThis.document = { + getElementById: (id) => { + reads++; + if (id === 'suiteSelectSuites') return { value: suiteSelect, options: options.map(value => ({ value })) }; + if (id === 'suiteFolder') return { innerText: folder }; + return null; + }, + }; + } + const kept = (exclude) => suites.filter(suite => !exclude(suite)).map(suite => suite.full_name); + + afterEach(() => { + delete globalThis.document; + settings.switch.suitePathsSuiteSection = false; + }); + + it('never excludes data that is not suite data', () => { + expect(get_suite_data_exclusion('test')(suites[0])).toBe(false); + }); + + it('keeps every suite with all folders and all suites selected', () => { + stub_document('All Suites Separate', [], 'All'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login', 'Web.Cart', 'Api.Login']); + }); + + it('keeps the selected suite by name, or by full name with suite paths', () => { + stub_document('Login', [], 'All'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login', 'Api.Login']); + settings.switch.suitePathsSuiteSection = true; + stub_document('Api.Login', [], 'All'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Api.Login']); + }); + + it('keeps the suites of the selected folder that are in the suite select', () => { + stub_document('All Suites Combined', ['All Suites Separate', 'All Suites Combined', 'Login', 'Cart'], 'Web'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login', 'Web.Cart']); + }); + + it('keeps the selected suite inside the selected folder', () => { + stub_document('Login', [], 'Web'); + expect(kept(get_suite_data_exclusion('suite'))).toEqual(['Web.Login']); + }); + + it('reads the selected filters once instead of once per suite', () => { + stub_document('Login', ['Login', 'Cart'], 'Web'); + const exclude = get_suite_data_exclusion('suite'); + const readsAfterSetup = reads; + suites.forEach(suite => exclude(suite)); + expect(reads).toBe(readsAfterSetup); + }); +}); diff --git a/tests/javascript/graph_data/messages_config.test.js b/tests/javascript/graph_data/messages_config.test.js new file mode 100644 index 00000000..8981dabe --- /dev/null +++ b/tests/javascript/graph_data/messages_config.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; + +// message_config is module state, so the message config tests live in their own file +vi.mock('@js/variables/settings.js', () => ({ + settings: { show: { aliases: 'run_start' }, switch: { testRerunView: 'reruns' } }, + get_run_label: (item) => item.run_start, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + failedConfig: { backgroundColor: 'rgba(206, 62, 1, 0.7)', borderColor: '#ce3e01' }, + rerunBorderColor: '#36a2eb', + rerunBorderWidth: 3, +})); +vi.mock('@js/variables/data.js', () => ({ + message_config: ['Element ${locator} not found', 'Timeout after * seconds'], +})); +vi.mock('@js/graph_data/helpers.js', async () => ({ + ...(await vi.importActual('@js/graph_data/helpers.js')), + convert_timeline_data: (datasets) => datasets, +})); +vi.mock('@js/common.js', () => ({ + strip_tz_suffix: (s) => s.replace(/[+-]\d{2}:\d{2}$/, ''), +})); + +import { get_messages_data } from '@js/graph_data/messages.js'; + +function make_test(run_start, message) { + return { name: 'Test', run_start, run_alias: run_start, passed: 0, failed: 1, skipped: 0, elapsed_s: 1, message, attempts: '' }; +} + +describe('get_messages_data with a message config', () => { + const data = [ + make_test('2025-01-15 10:00:00', 'Element id:login not found'), + make_test('2025-01-15 10:00:00', 'Timeout after 5 seconds'), + make_test('2025-01-16 10:00:00', 'Element id:cart not found'), + make_test('2025-01-16 10:00:00', 'Element id:cart not found'), + make_test('2025-01-17 10:00:00', 'Something else'), + ]; + + it('merges the messages that match a rule in the bar graph', () => { + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels).toEqual(['Element ${locator} not found', 'Something else', 'Timeout after * seconds']); + expect(graphData.datasets[0].data).toEqual([2, 1, 1]); + }); + + it('puts every matching message in the cell of its rule and run in the timeline', () => { + const [graphData, runStarts, pointMeta] = get_messages_data('test', 'timeline', data); + expect(runStarts).toEqual(['2025-01-15 10:00:00', '2025-01-16 10:00:00', '2025-01-17 10:00:00']); + const cells = graphData.datasets.map(dataset => `${dataset.label}@${dataset.data[0].x[0]}`); + expect(cells).toEqual([ + 'Element ${locator} not found@0', + 'Timeout after * seconds@0', + 'Element ${locator} not found@1', + 'Something else@2', + ]); + expect(pointMeta['Element ${locator} not found::1'].message).toBe('Element id:cart not found'); + }); +}); diff --git a/tests/python/test_dashboard.py b/tests/python/test_dashboard.py index fd1c5180..aaea2c05 100644 --- a/tests/python/test_dashboard.py +++ b/tests/python/test_dashboard.py @@ -5,6 +5,15 @@ import pytest from robotframework_dashboard.dashboard import DashboardGenerator +EMBEDDED_PAYLOAD_ORDER = ["runs", "suites", "tests", "keywords", "exceptions"] + + +def _embedded_payload(content, name): + """Decode one embedded data payload, load_data() in data.js decodes them in this order.""" + payloads = re.findall(r'decode_and_decompress\("([^"]+)"\)', content) + assert len(payloads) == len(EMBEDDED_PAYLOAD_ORDER), "data payloads not found in dashboard" + return json.loads(zlib.decompress(base64.b64decode(payloads[EMBEDDED_PAYLOAD_ORDER.index(name)]))) + def test_compress_and_encode_returns_string(): result = DashboardGenerator()._compress_and_encode({"key": "value"}) @@ -317,9 +326,7 @@ def test_generate_dashboard_uselogs_embeds_relative_paths(tmp_path): ) content = dashboard.read_text(encoding="utf-8") # Extract the base64 payload for runs - match = re.search(r'const runs = decode_and_decompress\("([^"]+)"\)', content) - assert match, "runs payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "runs") assert decoded[0]["path"] == "output.xml" assert str(tmp_path).replace("\\", "/") not in decoded[0]["path"] @@ -346,9 +353,7 @@ def test_generate_dashboard_server_mode_keeps_absolute_paths(tmp_path): no_autoupdate=False, ) content = dashboard.read_text(encoding="utf-8") - match = re.search(r'const runs = decode_and_decompress\("([^"]+)"\)', content) - assert match, "runs payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "runs") assert decoded[0]["path"] == abs_path @@ -374,9 +379,7 @@ def test_generate_dashboard_embeds_exceptions(tmp_path): no_autoupdate=False, ) content = dashboard.read_text(encoding="utf-8") - match = re.search(r'const exceptions = decode_and_decompress\("([^"]+)"\)', content) - assert match, "exceptions payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "exceptions") assert decoded[0]["message"] == "Timeout error" assert decoded[0]["amount"] == 2 @@ -401,7 +404,5 @@ def test_generate_dashboard_missing_exceptions_key_defaults_to_empty(tmp_path): ) content = dashboard.read_text(encoding="utf-8") import json, zlib, base64, re - match = re.search(r'const exceptions = decode_and_decompress\("([^"]+)"\)', content) - assert match, "exceptions payload not found in dashboard" - decoded = json.loads(zlib.decompress(base64.b64decode(match.group(1)))) + decoded = _embedded_payload(content, "exceptions") assert decoded == []