@matware/e2e-runner 1.5.0 → 1.5.1

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.
@@ -32,11 +32,12 @@ function readChunks(buf) {
32
32
  return chunks;
33
33
  }
34
34
 
35
- function decodePNG(filePath) {
36
- const buf = fs.readFileSync(filePath);
35
+ function decodePNG(input) {
36
+ // Accepts a file path or an in-memory PNG Buffer (capture-time checks).
37
+ const buf = Buffer.isBuffer(input) ? input : fs.readFileSync(input);
37
38
 
38
- if (buf.compare(PNG_SIGNATURE, 0, 8, 0, 8) !== 0) {
39
- throw new Error(`Not a valid PNG file: ${filePath}`);
39
+ if (buf.length < 8 || buf.compare(PNG_SIGNATURE, 0, 8, 0, 8) !== 0) {
40
+ throw new Error(`Not a valid PNG file: ${Buffer.isBuffer(input) ? '<buffer>' : input}`);
40
41
  }
41
42
 
42
43
  const chunks = readChunks(buf);
@@ -458,19 +459,19 @@ function buildMaskLookup(regions, imgWidth, imgHeight) {
458
459
  * frame. Non-PNG or undecodable files are reported as not-blank so they are
459
460
  * never deleted by mistake.
460
461
  *
461
- * @param {string} filePath
462
+ * @param {string|Buffer} input — PNG file path, or an in-memory PNG buffer
462
463
  * @param {{tolerance?:number, maxOutlierFraction?:number, maxSamples?:number}} [opts]
463
464
  * @returns {{blank:boolean, color?:{r:number,g:number,b:number}, brightness?:number,
464
465
  * width?:number, height?:number, outlierFraction?:number, error?:string}}
465
466
  */
466
- export function isBlankImage(filePath, opts = {}) {
467
+ export function isBlankImage(input, opts = {}) {
467
468
  const tolerance = opts.tolerance ?? 10;
468
469
  const maxOutlierFraction = opts.maxOutlierFraction ?? 0.005; // ≤0.5% off-color pixels
469
470
  const maxSamples = opts.maxSamples ?? 120000;
470
471
 
471
472
  let img;
472
473
  try {
473
- img = decodePNG(filePath);
474
+ img = decodePNG(input);
474
475
  } catch (error) {
475
476
  return { blank: false, error: error.message };
476
477
  }
@@ -23,6 +23,27 @@ function prettyJson(str){
23
23
  try{return JSON.stringify(JSON.parse(str),null,2)}catch(e){return str}
24
24
  }
25
25
 
26
+ /* Lightweight JSON syntax highlighter. Escapes HTML, then wraps tokens in
27
+ colored spans. Input should already be pretty-printed (prettyJson). */
28
+ function highlightJson(text){
29
+ var esc=String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
30
+ return esc.replace(/"(?:\\.|[^"\\])*"|\b(?:true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+\-]?\d+)?\b/g,function(m,off,s){
31
+ var cls;
32
+ if(m[0]==='"'){cls=/^\s*:/.test(s.slice(off+m.length))?'jn-key':'jn-str'}
33
+ else if(m==='true'||m==='false'){cls='jn-bool'}
34
+ else if(m==='null'){cls='jn-null'}
35
+ else{cls='jn-num'}
36
+ return '<span class="'+cls+'">'+m+'</span>';
37
+ });
38
+ }
39
+
40
+ /* Builds a <pre> with syntax-highlighted JSON content. */
41
+ function jsonPre(text){
42
+ var p=document.createElement('pre');
43
+ p.innerHTML=highlightJson(text);
44
+ return p;
45
+ }
46
+
26
47
  function fmtHeaders(h){
27
48
  if(!h||typeof h!=='object')return '';
28
49
  return Object.keys(h).map(function(k){return k+': '+h[k]}).join('\n');
@@ -109,7 +130,7 @@ function buildNetRow(n){
109
130
  }
110
131
  if(n.requestBody){
111
132
  var rbText=prettyJson(n.requestBody);
112
- sections.push(buildNdSection('Request Body',el('pre',null,rbText),null,rbText));
133
+ sections.push(buildNdSection('Request Body',jsonPre(rbText),null,rbText));
113
134
  }
114
135
  if(n.responseHeaders){
115
136
  var rhCount=Object.keys(n.responseHeaders).length;
@@ -117,7 +138,7 @@ function buildNetRow(n){
117
138
  }
118
139
  if(n.responseBody){
119
140
  var respText=prettyJson(n.responseBody);
120
- sections.push(buildNdSection('Response Body',el('pre',null,respText),null,respText));
141
+ sections.push(buildNdSection('Response Body',jsonPre(respText),null,respText));
121
142
  }
122
143
  detail=el('div',{className:'rd-net-detail'},sections);
123
144
  row.addEventListener('click',function(e){e.stopPropagation();row.classList.toggle('open')});
@@ -309,7 +309,8 @@ function loadDetailInline(id,detailTr){
309
309
  var stateCls=a.success?'pass':'fail';
310
310
  var icon=a.success?'\u2714':'\u2718';
311
311
 
312
- // Thumbnail: prefer autoScreenshot, fall back to action's own screenshot
312
+ // Thumbnail: prefer autoScreenshot, fall back to action's own screenshot.
313
+ // Data-capture steps (raw JSON saved instead of a screenshot) get a {} thumb.
313
314
  var thumbPath=a.autoScreenshot||a.screenshot||null;
314
315
  var thumb;
315
316
  if(thumbPath){
@@ -317,6 +318,10 @@ function loadDetailInline(id,detailTr){
317
318
  var img=document.createElement('img');
318
319
  img.src=src;img.alt=label;img.loading='lazy';
319
320
  thumb=el('div',{className:'sl-thumb',onclick:function(e){e.stopPropagation();openModal(src)}},[img]);
321
+ }else if(a.dataCapture){
322
+ (function(dc){
323
+ thumb=el('div',{className:'sl-thumb sl-thumb-json',title:'Raw JSON response \u2014 click to view',onclick:function(e){e.stopPropagation();openJsonModal(dc)}},[el('span',null,'{\u2009}')]);
324
+ })(a.dataCapture);
320
325
  }else{
321
326
  thumb=el('div',{className:'sl-thumb sl-thumb-empty',title:'No screenshot for this step'},[el('span',null,'\u25A1')]);
322
327
  }
@@ -326,6 +331,12 @@ function loadDetailInline(id,detailTr){
326
331
  if(a.selector)chips.appendChild(el('span',{className:'sl-chip sl-chip-sel'},[el('span',{className:'sl-chip-k'},'sel'),el('span',{className:'sl-chip-v'},a.selector)]));
327
332
  if(a.text)chips.appendChild(el('span',{className:'sl-chip'},[el('span',{className:'sl-chip-k'},'text'),el('span',{className:'sl-chip-v'},String(a.text))]));
328
333
  if(a.value!=null&&a.value!=='')chips.appendChild(el('span',{className:'sl-chip'},[el('span',{className:'sl-chip-k'},'val'),el('span',{className:'sl-chip-v'},String(a.value))]));
334
+ if(a.dataCapture)(function(dc){
335
+ chips.appendChild(el('span',{className:'sl-chip sl-chip-json',title:'Raw JSON response saved for this step \u2014 click to view',onclick:function(e){e.stopPropagation();openJsonModal(dc)}},[
336
+ el('span',{className:'sl-chip-k'},'JSON'),
337
+ el('span',{className:'sl-chip-v'},dc.split('/').pop())
338
+ ]));
339
+ })(a.dataCapture);
329
340
 
330
341
  var retryBadge=(a.actionRetries&&a.actionRetries>0)?el('span',{className:'sl-retry'},'\u21BB '+a.actionRetries):null;
331
342
 
@@ -456,6 +467,15 @@ function loadDetailInline(id,detailTr){
456
467
  }
457
468
 
458
469
  /* ── Screenshots ── */
470
+ /* Session memory: which test groups the user expanded (survives refreshes, not reloads) */
471
+ var SS_EXPANDED={};
472
+ function buildGalleryItem(f){
473
+ var src='/api/image?path='+encodeURIComponent(f.path);
474
+ var img=document.createElement('img');img.src=src;img.alt=f.name;img.loading='lazy';
475
+ var capEl=el('div',{className:'cap'},[el('span',{className:'cap-name'},f.name)]);
476
+ (function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,f.path);
477
+ return el('div',{className:'gallery-item','data-path':f.path,onclick:function(){openModal(src)}},[img,capEl]);
478
+ }
459
479
  function refreshScreenshots(){
460
480
  var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
461
481
  gal.textContent='';
@@ -464,12 +484,50 @@ function refreshScreenshots(){
464
484
  if(!Array.isArray(files)||!files.length){empty.style.display='block';empty.querySelector('p').textContent='No screenshots for this project.';$('#badgeScreenshots').textContent='0';return}
465
485
  empty.style.display='none';
466
486
  $('#badgeScreenshots').textContent=files.length;
487
+ /* Group by test name; files without one go to a trailing "Other" bucket */
488
+ var groups={},order=[];
467
489
  files.forEach(function(f){
468
- var src='/api/image?path='+encodeURIComponent(f.path);
469
- var img=document.createElement('img');img.src=src;img.alt=f.name;img.loading='lazy';
470
- var capEl=el('div',{className:'cap'},[el('span',{className:'cap-name'},f.name)]);
471
- (function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,f.path);
472
- gal.appendChild(el('div',{className:'gallery-item','data-path':f.path,onclick:function(){openModal(src)}},[img,capEl]));
490
+ var key=f.testName||'__other__';
491
+ if(!groups[key]){groups[key]=[];order.push(key)}
492
+ groups[key].push(f);
493
+ });
494
+ function fileTs(f){var m=f.name.match(/(\d{10,})\.[a-z]+$/i);return m?parseInt(m[1],10):0}
495
+ order.sort(function(a,b){
496
+ if(a==='__other__')return 1;
497
+ if(b==='__other__')return -1;
498
+ var ma=0,mb=0;
499
+ groups[a].forEach(function(f){var t=fileTs(f);if(t>ma)ma=t});
500
+ groups[b].forEach(function(f){var t=fileTs(f);if(t>mb)mb=t});
501
+ return mb-ma;
502
+ });
503
+ order.forEach(function(key){
504
+ var items=groups[key],label=key==='__other__'?'Other':key;
505
+ var grid=el('div',{className:'gallery'});
506
+ var materialized=false;
507
+ function materialize(){
508
+ if(materialized)return;materialized=true;
509
+ items.forEach(function(f){grid.appendChild(buildGalleryItem(f))});
510
+ }
511
+ var expanded=!!SS_EXPANDED[key];
512
+ var body=el('div',{className:'gallery-group-body'},[grid]);
513
+ var group=el('div',{className:'gallery-group'+(expanded?' open':'')},[]);
514
+ var header=el('div',{className:'gallery-group-header',onclick:function(){
515
+ expanded=!expanded;
516
+ if(expanded){SS_EXPANDED[key]=true;materialize()}else{delete SS_EXPANDED[key]}
517
+ group.classList.toggle('open',expanded);
518
+ if(expanded)body.removeAttribute('hidden');else body.setAttribute('hidden','');
519
+ }},[
520
+ el('span',{className:'gg-arrow'},'▶'),
521
+ el('span',{className:'gg-name'},label),
522
+ el('span',{className:'gg-count'},items.length+' shot'+(items.length===1?'':'s')),
523
+ el('span',{className:'gg-blank-badge',hidden:''},''),
524
+ ]);
525
+ group.appendChild(header);
526
+ group.appendChild(body);
527
+ if(expanded){materialize()}else{body.setAttribute('hidden','')}
528
+ /* Expose to the blank-scan so it can materialize collapsed groups on demand */
529
+ group._ssItems=items;group._ssMaterialize=materialize;
530
+ gal.appendChild(group);
473
531
  });
474
532
  resetBlankBar();
475
533
  }).catch(function(){});
@@ -479,6 +537,7 @@ function refreshScreenshots(){
479
537
  function resetBlankBar(){
480
538
  var bar=$('#ssBlankBar');if(bar)bar.hidden=true;
481
539
  $$('#screenshotGallery .gallery-item.blank-flagged').forEach(function(it){it.classList.remove('blank-flagged')});
540
+ $$('#screenshotGallery .gg-blank-badge').forEach(function(b){b.textContent='';b.setAttribute('hidden','')});
482
541
  S.blankPaths=null;
483
542
  }
484
543
  function scanBlankScreenshots(){
@@ -494,10 +553,18 @@ function scanBlankScreenshots(){
494
553
  return;
495
554
  }
496
555
  S.blankPaths=blanks.map(function(b){return b.path});
497
- var found=0;
556
+ /* Materialize collapsed groups that contain blanks, flag items, badge the headers */
557
+ var blankSet={};S.blankPaths.forEach(function(p){blankSet[p]=true});
558
+ $$('#screenshotGallery .gallery-group').forEach(function(g){
559
+ var inGroup=(g._ssItems||[]).filter(function(it){return blankSet[it.path]}).length;
560
+ if(!inGroup)return;
561
+ if(g._ssMaterialize)g._ssMaterialize();
562
+ var badge=g.querySelector('.gg-blank-badge');
563
+ if(badge){badge.textContent=inGroup+' blank'+(inGroup===1?'':'s');badge.removeAttribute('hidden')}
564
+ });
498
565
  S.blankPaths.forEach(function(p){
499
566
  var item=$('#screenshotGallery .gallery-item[data-path="'+(window.CSS&&CSS.escape?CSS.escape(p):p)+'"]');
500
- if(item){item.classList.add('blank-flagged');found++}
567
+ if(item)item.classList.add('blank-flagged');
501
568
  });
502
569
  $('#ssBlankMsg').textContent=blanks.length+' blank image'+(blanks.length===1?'':'s')+' of '+data.scanned+' scanned';
503
570
  $('#ssBlankBar').hidden=false;
@@ -792,8 +859,26 @@ $('#btnExportLearnings').addEventListener('click',function(){
792
859
  });
793
860
 
794
861
  /* ── Modal ── */
795
- function openModal(src){$('#modalImg').src=src;$('#modal').classList.add('open')}
862
+ function openModal(src){
863
+ $('#modalImg').src=src;$('#modalImg').removeAttribute('hidden');
864
+ $('#modalJson').setAttribute('hidden','');
865
+ $('#modal').classList.add('open');
866
+ }
867
+ function openJsonModal(filePath){
868
+ fetch('/api/image?path='+encodeURIComponent(filePath)).then(function(r){
869
+ if(!r.ok)throw new Error('not found');
870
+ return r.text();
871
+ }).then(function(text){
872
+ $('#modalImg').setAttribute('hidden','');
873
+ var pre=$('#modalJson');
874
+ pre.innerHTML=highlightJson(prettyJson(text));
875
+ pre.removeAttribute('hidden');
876
+ $('#modal').classList.add('open');
877
+ }).catch(function(){showToast('Could not load JSON capture','error')});
878
+ }
796
879
  $('#modal').addEventListener('click',function(){$('#modal').classList.remove('open')});
880
+ /* Allow selecting/copying JSON text without closing the modal */
881
+ $('#modalJson').addEventListener('click',function(e){e.stopPropagation()});
797
882
 
798
883
  /* ══════════════════════════════════════════════════════════════════
799
884
  Screenshot Replay Player — plays a run's per-step screenshots
@@ -248,6 +248,23 @@ tbody tr.selected td{
248
248
  cursor:default;
249
249
  box-shadow:0 0 0 1px rgba(158,242,106,.12),0 40px 80px rgba(0,0,0,.6);
250
250
  }
251
+ .modal pre{
252
+ max-width:min(900px,100%);max-height:90vh;overflow:auto;
253
+ margin:0;padding:20px 24px;
254
+ background:var(--bg-2);
255
+ border:1px solid var(--border);border-radius:var(--r);
256
+ font-family:var(--mono);font-size:12px;line-height:1.6;
257
+ color:var(--text);cursor:text;user-select:text;
258
+ box-shadow:0 0 0 1px rgba(158,242,106,.12),0 40px 80px rgba(0,0,0,.6);
259
+ }
260
+ .modal pre[hidden],.modal img[hidden]{display:none}
261
+
262
+ /* ── JSON syntax highlighting (modal + network body panels) ── */
263
+ .jn-key{color:var(--beacon)}
264
+ .jn-str{color:var(--amber)}
265
+ .jn-num{color:var(--phosphor)}
266
+ .jn-bool{color:var(--red);font-weight:600}
267
+ .jn-null{color:var(--red);opacity:.65;font-style:italic}
251
268
 
252
269
  /* ── Toasts ── */
253
270
  .toast-container{
@@ -439,10 +439,49 @@ tr.expanded td:first-child::before{
439
439
  .hb-link span{font-size:10px;color:var(--beacon);font-weight:700;letter-spacing:.18em;text-transform:uppercase}
440
440
 
441
441
  /* ═══════════════════ Screenshots ═══════════════════ */
442
+ .gallery-group{
443
+ border:1px solid var(--border);
444
+ border-radius:var(--r);
445
+ background:var(--surface);
446
+ margin-bottom:10px;
447
+ overflow:hidden;
448
+ }
449
+ .gallery-group-header{
450
+ display:flex;align-items:center;gap:10px;
451
+ padding:10px 14px;cursor:pointer;user-select:none;
452
+ background:var(--bg-2);
453
+ transition:background .15s;
454
+ }
455
+ .gallery-group-header:hover{background:var(--surface)}
456
+ .gallery-group .gg-arrow{
457
+ font-size:9px;color:var(--text2);
458
+ transition:transform .15s;flex-shrink:0;
459
+ }
460
+ .gallery-group.open .gg-arrow{transform:rotate(90deg)}
461
+ .gallery-group .gg-name{
462
+ font-family:var(--mono);font-size:12px;font-weight:600;
463
+ color:var(--text);
464
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;
465
+ }
466
+ .gallery-group .gg-count{
467
+ font-family:var(--mono);font-size:10px;color:var(--text2);
468
+ padding:2px 8px;border-radius:999px;
469
+ background:var(--bg);border:1px solid var(--border);
470
+ flex-shrink:0;margin-left:auto;
471
+ }
472
+ .gallery-group .gg-blank-badge{
473
+ font-family:var(--mono);font-size:9px;font-weight:700;
474
+ padding:2px 8px;border-radius:999px;
475
+ background:var(--amber-dim);color:var(--amber);border:1px solid var(--amber);
476
+ flex-shrink:0;
477
+ }
478
+ .gallery-group .gg-blank-badge[hidden]{display:none}
479
+ .gallery-group-body{padding:12px}
480
+ .gallery-group-body[hidden]{display:none}
442
481
  .gallery{
443
482
  display:grid;
444
- grid-template-columns:repeat(auto-fill,minmax(230px,1fr));
445
- gap:14px;
483
+ grid-template-columns:repeat(auto-fill,minmax(150px,1fr));
484
+ gap:10px;
446
485
  }
447
486
  .gallery-item{
448
487
  background:var(--surface);
@@ -456,11 +495,11 @@ tr.expanded td:first-child::before{
456
495
  content:'';position:absolute;top:0;left:0;width:18px;height:1px;
457
496
  background:var(--phosphor);opacity:.4;z-index:1;
458
497
  }
459
-
498
+ .gallery-item:hover{
460
499
  border-color:var(--ui-accent);transform:translateY(-2px);
461
500
  box-shadow:0 12px 28px rgba(0,0,0,.4);
462
501
  }
463
- .gallery-item img{width:100%;height:160px;object-fit:cover;display:block}
502
+ .gallery-item img{width:100%;aspect-ratio:1/1;height:auto;object-fit:cover;object-position:top center;display:block}
464
503
  .gallery-item .cap{
465
504
  padding:8px 12px;font-size:10px;
466
505
  color:var(--text2);display:flex;align-items:center;gap:6px;
@@ -735,6 +774,11 @@ tr.expanded td:first-child::before{
735
774
  background:repeating-linear-gradient(45deg,var(--surface2),var(--surface2) 6px,var(--bg-2) 6px,var(--bg-2) 12px);
736
775
  }
737
776
  .sl-thumb-empty:hover{transform:none;box-shadow:none}
777
+ .sl-thumb-json{
778
+ display:flex;align-items:center;justify-content:center;
779
+ color:var(--beacon);font-family:var(--mono);font-size:18px;font-weight:700;
780
+ background:var(--bg-2);
781
+ }
738
782
  .sl-thumb-err{border-color:var(--crimson)}
739
783
 
740
784
  .sl-info{display:flex;flex-direction:column;gap:6px;min-width:0}
@@ -797,6 +841,9 @@ tr.expanded td:first-child::before{
797
841
  }
798
842
  .sl-chip-v{color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:280px}
799
843
  .sl-chip-sel .sl-chip-v{color:var(--beacon)}
844
+ .sl-chip-json{cursor:pointer;border-color:var(--beacon)}
845
+ .sl-chip-json .sl-chip-k{color:var(--beacon)}
846
+ .sl-chip-json:hover{background:var(--beacon-dim)}
800
847
 
801
848
  .sl-bar{
802
849
  width:100%;height:3px;
@@ -266,7 +266,7 @@
266
266
  <button class="btn sm" id="ssBlankCancelBtn">Cancel</button>
267
267
  </div>
268
268
  </div>
269
- <div class="gallery" id="screenshotGallery"></div>
269
+ <div class="gallery-groups" id="screenshotGallery"></div>
270
270
  <div class="empty" id="screenshotsEmpty" style="display:none">
271
271
  <div class="empty-icon">&#9635;</div>
272
272
  <p>Select a project to view screenshots.</p>
@@ -512,7 +512,7 @@
512
512
  </div>
513
513
  </div>
514
514
  </div>
515
- <div class="modal" id="modal"><img id="modalImg" src="" alt=""></div>
515
+ <div class="modal" id="modal"><img id="modalImg" src="" alt=""><pre id="modalJson" hidden></pre></div>
516
516
 
517
517
  <!-- ── Quick Search Palette ── -->
518
518
  <div class="qs-modal" id="qsModal" aria-hidden="true">