@matware/e2e-runner 1.3.1 → 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.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/LICENSE +1 -1
  4. package/README.md +491 -225
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +7 -4
  7. package/bin/cli.js +93 -19
  8. package/package.json +4 -3
  9. package/skills/e2e-testing/SKILL.md +5 -3
  10. package/skills/e2e-testing/references/action-types.md +35 -18
  11. package/skills/e2e-testing/references/test-json-format.md +23 -0
  12. package/skills/e2e-testing/references/troubleshooting.md +2 -26
  13. package/src/actions.js +181 -15
  14. package/src/config.js +6 -0
  15. package/src/dashboard.js +185 -9
  16. package/src/db.js +26 -0
  17. package/src/mcp-tools.js +238 -69
  18. package/src/module-analysis.js +247 -0
  19. package/src/module-resolver.js +35 -2
  20. package/src/narrate.js +33 -1
  21. package/src/pool-manager.js +46 -1
  22. package/src/pool.js +177 -20
  23. package/src/runner.js +144 -19
  24. package/src/visual-diff.js +74 -4
  25. package/src/websocket.js +14 -3
  26. package/src/wizard.js +184 -0
  27. package/templates/build-dashboard.js +3 -0
  28. package/templates/dashboard/js/api.js +60 -3
  29. package/templates/dashboard/js/init.js +46 -0
  30. package/templates/dashboard/js/keyboard.js +8 -7
  31. package/templates/dashboard/js/quicksearch.js +277 -0
  32. package/templates/dashboard/js/state.js +61 -7
  33. package/templates/dashboard/js/toast.js +1 -1
  34. package/templates/dashboard/js/utils.js +23 -2
  35. package/templates/dashboard/js/view-live.js +235 -42
  36. package/templates/dashboard/js/view-runs.js +469 -42
  37. package/templates/dashboard/js/view-tests.js +157 -16
  38. package/templates/dashboard/js/view-tools.js +234 -0
  39. package/templates/dashboard/js/view-watch.js +2 -2
  40. package/templates/dashboard/js/websocket.js +33 -3
  41. package/templates/dashboard/styles/base.css +489 -53
  42. package/templates/dashboard/styles/components.css +736 -84
  43. package/templates/dashboard/styles/view-live.css +459 -78
  44. package/templates/dashboard/styles/view-runs.css +826 -177
  45. package/templates/dashboard/styles/view-tests.css +440 -77
  46. package/templates/dashboard/styles/view-tools.css +206 -0
  47. package/templates/dashboard/styles/view-watch.css +198 -41
  48. package/templates/dashboard/template.html +356 -58
  49. package/templates/dashboard.html +5354 -722
  50. package/templates/docker-compose-lightpanda.yml +7 -0
@@ -67,8 +67,8 @@ function renderRunsHealthBanner(){
67
67
  el('div',{className:'hb-lbl'},'Top Error ('+h.topErrorPattern.count+'x)')
68
68
  ]));
69
69
  }
70
- banner.appendChild(el('div',{className:'hb-link',onclick:function(){var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
71
- el('span',null,'\u2192 View Learnings')
70
+ banner.appendChild(el('div',{className:'hb-link',onclick:function(){showView('insights')}},[
71
+ el('span',null,'\u2192 View Insights')
72
72
  ]));
73
73
  }).catch(function(){});
74
74
  }
@@ -275,53 +275,132 @@ function loadDetailInline(id,detailTr){
275
275
  body.appendChild(errDiv);
276
276
  }
277
277
 
278
- // Actions panel
278
+ // Storyline \u2014 unified per-step cards with thumbnails, narrative, duration bar
279
279
  if(r.actions&&r.actions.length){
280
280
  var passCount=r.actions.filter(function(a){return a.success}).length;
281
281
  var failCount=r.actions.length-passCount;
282
- var actHead=el('div',{className:'rd-net-head'},[
282
+ var maxDur=Math.max.apply(null,r.actions.map(function(a){return a.duration||0}).concat([1]));
283
+ var hashes=r.screenshotHashes||{};
284
+
285
+ // "Play replay" button \u2014 only shown if at least one step has a screenshot
286
+ var hasFrames=r.actions.some(function(a){return a.autoScreenshot||a.screenshot})||(!r.success&&r.errorScreenshot);
287
+ var replayBtn=hasFrames?el('button',{
288
+ className:'rd-replay-btn',
289
+ title:'Replay step-by-step',
290
+ onclick:function(e){e.stopPropagation();openReplay(r.actions,r)}
291
+ },'\u25B6 Replay'):null;
292
+ var slHead=el('div',{className:'rd-net-head open'},[
283
293
  el('span',{className:'net-arrow'},'\u25B6'),
284
- el('span',{className:'net-title'},'Actions'),
294
+ el('span',{className:'net-title'},'Storyline'),
285
295
  el('div',{className:'net-stats'},[
286
296
  el('span',{className:'net-stat'},[document.createTextNode('Steps: '),el('strong',null,String(r.actions.length))]),
287
- failCount?el('span',{className:'net-stat has-err'},[document.createTextNode('Failed: '),el('strong',null,String(failCount))]):null
288
- ])
297
+ failCount?el('span',{className:'net-stat has-err'},[document.createTextNode('Failed: '),el('strong',null,String(failCount))]):null,
298
+ el('span',{className:'net-stat'},[document.createTextNode('Passed: '),el('strong',null,String(passCount))])
299
+ ]),
300
+ replayBtn
289
301
  ]);
290
- var actBody=el('div',{className:'rd-net-body',style:'padding:8px 14px'});
291
- r.actions.forEach(function(a){
302
+ var slBody=el('div',{className:'storyline'});
303
+
304
+ r.actions.forEach(function(a,idx){
305
+ var stepNum=String(idx+1).padStart(2,'0');
292
306
  var label=a.narrative||a.type;
293
307
  var durText=a.duration!=null?dur(a.duration):'';
294
- var retryBadge=null;
295
- if(a.actionRetries&&a.actionRetries>0){
296
- retryBadge=el('span',{className:'badge flaky',style:'font-size:9px;padding:1px 5px'},'\u21BB x'+a.actionRetries);
308
+ var durPct=a.duration?Math.max(2,Math.round((a.duration/maxDur)*100)):0;
309
+ var stateCls=a.success?'pass':'fail';
310
+ var icon=a.success?'\u2714':'\u2718';
311
+
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.
314
+ var thumbPath=a.autoScreenshot||a.screenshot||null;
315
+ var thumb;
316
+ if(thumbPath){
317
+ var src='/api/image?path='+encodeURIComponent(thumbPath);
318
+ var img=document.createElement('img');
319
+ img.src=src;img.alt=label;img.loading='lazy';
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);
325
+ }else{
326
+ thumb=el('div',{className:'sl-thumb sl-thumb-empty',title:'No screenshot for this step'},[el('span',null,'\u25A1')]);
297
327
  }
298
- actBody.appendChild(el('div',{className:'lt-step'},[
299
- el('span',{className:'step-icon '+(a.success?'ok':'fail')},a.success?'\u2714':'\u2718'),
300
- el('span',{className:'step-detail',style:'flex:1'},label),
328
+
329
+ // Param chips (selector / text / value)
330
+ var chips=el('div',{className:'sl-chips'});
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)]));
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))]));
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);
340
+
341
+ var retryBadge=(a.actionRetries&&a.actionRetries>0)?el('span',{className:'sl-retry'},'\u21BB '+a.actionRetries):null;
342
+
343
+ var hashBadge=null;
344
+ if(thumbPath){
345
+ if(hashes[thumbPath]){hashBadge=createHashBadge(hashes[thumbPath])}
346
+ else{(function(holder,fp){ssHash(fp).then(function(h){if(h)holder.appendChild(createHashBadge(h))})})}
347
+ }
348
+
349
+ var titleRow=el('div',{className:'sl-title'},[
350
+ el('span',{className:'sl-num'},stepNum),
351
+ el('span',{className:'sl-icon '+stateCls},icon),
352
+ el('span',{className:'sl-type'},a.type),
353
+ el('span',{className:'sl-narr'},label),
301
354
  retryBadge,
302
- el('span',{className:'step-dur'},durText)
303
- ]));
304
- });
305
- actHead.addEventListener('click',function(){actHead.classList.toggle('open')});
306
- body.appendChild(el('div',{className:'rd-net-panel'},[actHead,actBody]));
307
- }
355
+ el('span',{className:'sl-dur'},durText)
356
+ ]);
357
+
358
+ var durBar=el('div',{className:'sl-bar'},[el('div',{className:'sl-bar-fill '+stateCls,style:'width:'+durPct+'%'})]);
359
+
360
+ var info=el('div',{className:'sl-info'},[
361
+ titleRow,
362
+ chips.children.length?chips:null,
363
+ durBar
364
+ ]);
365
+
366
+ var card=el('div',{className:'sl-step '+stateCls},[thumb,info]);
367
+
368
+ if(!a.success&&a.error){
369
+ var errBlock=el('div',{className:'sl-err'},[
370
+ el('span',{className:'sl-err-tag'},'ERROR'),
371
+ el('span',{className:'sl-err-msg'},String(a.error))
372
+ ]);
373
+ card.appendChild(errBlock);
374
+ }
308
375
 
309
- // Screenshots
310
- var shots=[];
311
- var hashes=r.screenshotHashes||{};
312
- (r.screenshots||[]).forEach(function(p){shots.push({path:p,label:p.split('/').pop(),type:'screenshot',hash:hashes[p]||null})});
313
- if(r.errorScreenshot){shots.push({path:r.errorScreenshot,label:r.errorScreenshot.split('/').pop(),type:'error',hash:hashes[r.errorScreenshot]||null})}
314
- if(shots.length){
315
- var shotsWrap=el('div',{className:'rd-shots'});
316
- shots.forEach(function(s){
317
- var src='/api/image?path='+encodeURIComponent(s.path);
318
- var img=document.createElement('img');img.src=src;img.alt=s.label;img.loading='lazy';
319
- var capEl=el('div',{className:'rd-shot-cap'},[el('span',{className:'cap-name'},s.label)]);
320
- if(s.hash){capEl.appendChild(createHashBadge(s.hash))}
321
- else{(function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,s.path)}
322
- shotsWrap.appendChild(el('div',{className:'rd-shot'+(s.type==='error'?' err-shot':''),onclick:function(e){e.stopPropagation();openModal(src)}},[img,capEl]));
376
+ slBody.appendChild(card);
323
377
  });
324
- body.appendChild(shotsWrap);
378
+
379
+ // Final failure context card (uses test-level errorScreenshot if present)
380
+ if(!r.success&&r.errorScreenshot){
381
+ var errSrc='/api/image?path='+encodeURIComponent(r.errorScreenshot);
382
+ var errImg=document.createElement('img');errImg.src=errSrc;errImg.alt='Failure context';errImg.loading='lazy';
383
+ var errCard=el('div',{className:'sl-step sl-final-err'},[
384
+ el('div',{className:'sl-thumb sl-thumb-err',onclick:function(e){e.stopPropagation();openModal(errSrc)}},[errImg]),
385
+ el('div',{className:'sl-info'},[
386
+ el('div',{className:'sl-title'},[
387
+ el('span',{className:'sl-num'},'\u26A0'),
388
+ el('span',{className:'sl-icon fail'},'\u2718'),
389
+ el('span',{className:'sl-narr sl-final-msg'},'Page state at failure')
390
+ ]),
391
+ r.error?el('div',{className:'sl-err'},[el('span',{className:'sl-err-tag'},'TEST FAILED'),el('span',{className:'sl-err-msg'},String(r.error))]):null
392
+ ])
393
+ ]);
394
+ slBody.appendChild(errCard);
395
+ }
396
+
397
+ slHead.addEventListener('click',function(){slHead.classList.toggle('open');slBody.classList.toggle('hidden')});
398
+ body.appendChild(el('div',{className:'rd-net-panel rd-storyline-panel'},[slHead,slBody]));
399
+ } else if(r.errorScreenshot){
400
+ // No actions but we have an error screenshot \u2014 show it standalone
401
+ var errSrcOnly='/api/image?path='+encodeURIComponent(r.errorScreenshot);
402
+ var errImgOnly=document.createElement('img');errImgOnly.src=errSrcOnly;errImgOnly.loading='lazy';
403
+ body.appendChild(el('div',{className:'sl-final-err-standalone',onclick:function(e){e.stopPropagation();openModal(errSrcOnly)}},[errImgOnly]));
325
404
  }
326
405
 
327
406
  // Console logs
@@ -388,6 +467,15 @@ function loadDetailInline(id,detailTr){
388
467
  }
389
468
 
390
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
+ }
391
479
  function refreshScreenshots(){
392
480
  var gal=$('#screenshotGallery'),empty=$('#screenshotsEmpty');
393
481
  gal.textContent='';
@@ -396,16 +484,115 @@ function refreshScreenshots(){
396
484
  if(!Array.isArray(files)||!files.length){empty.style.display='block';empty.querySelector('p').textContent='No screenshots for this project.';$('#badgeScreenshots').textContent='0';return}
397
485
  empty.style.display='none';
398
486
  $('#badgeScreenshots').textContent=files.length;
487
+ /* Group by test name; files without one go to a trailing "Other" bucket */
488
+ var groups={},order=[];
399
489
  files.forEach(function(f){
400
- var src='/api/image?path='+encodeURIComponent(f.path);
401
- var img=document.createElement('img');img.src=src;img.alt=f.name;img.loading='lazy';
402
- var capEl=el('div',{className:'cap'},[el('span',{className:'cap-name'},f.name)]);
403
- (function(c,fp){ssHash(fp).then(function(h){c.appendChild(createHashBadge(h))})})(capEl,f.path);
404
- gal.appendChild(el('div',{className:'gallery-item',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);
405
531
  });
532
+ resetBlankBar();
406
533
  }).catch(function(){});
407
534
  }
408
535
 
536
+ /* ── Blank screenshot scan / delete ── */
537
+ function resetBlankBar(){
538
+ var bar=$('#ssBlankBar');if(bar)bar.hidden=true;
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','')});
541
+ S.blankPaths=null;
542
+ }
543
+ function scanBlankScreenshots(){
544
+ if(!S.project){showToast('Select a project first','error');return}
545
+ var btn=$('#ssScanBlankBtn');btn.disabled=true;var prev=btn.textContent;btn.textContent='Scanning…';
546
+ api('/api/db/projects/'+S.project+'/screenshots/blank-scan').then(function(data){
547
+ btn.disabled=false;btn.textContent=prev;
548
+ var blanks=(data&&data.blanks)||[];
549
+ $$('#screenshotGallery .gallery-item.blank-flagged').forEach(function(it){it.classList.remove('blank-flagged')});
550
+ if(!blanks.length){
551
+ S.blankPaths=null;$('#ssBlankBar').hidden=true;
552
+ showToast('No blank images found ('+((data&&data.scanned)||0)+' scanned)','info');
553
+ return;
554
+ }
555
+ S.blankPaths=blanks.map(function(b){return b.path});
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
+ });
565
+ S.blankPaths.forEach(function(p){
566
+ var item=$('#screenshotGallery .gallery-item[data-path="'+(window.CSS&&CSS.escape?CSS.escape(p):p)+'"]');
567
+ if(item)item.classList.add('blank-flagged');
568
+ });
569
+ $('#ssBlankMsg').textContent=blanks.length+' blank image'+(blanks.length===1?'':'s')+' of '+data.scanned+' scanned';
570
+ $('#ssBlankBar').hidden=false;
571
+ }).catch(function(){
572
+ btn.disabled=false;btn.textContent=prev;
573
+ showToast('Blank scan failed','error');
574
+ });
575
+ }
576
+ function deleteBlankScreenshots(){
577
+ if(!S.blankPaths||!S.blankPaths.length)return;
578
+ var btn=$('#ssBlankDeleteBtn');btn.disabled=true;var prev=btn.textContent;btn.textContent='Deleting…';
579
+ fetch('/api/screenshots/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths:S.blankPaths})})
580
+ .then(function(r){return r.json()}).then(function(res){
581
+ btn.disabled=false;btn.textContent=prev;
582
+ if(res&&res.error){showToast('Delete failed: '+res.error,'error');return}
583
+ var n=res.deleted||0,failed=(res.failed&&res.failed.length)||0;
584
+ showToast('Deleted '+n+' blank image'+(n===1?'':'s')+(failed?(' · '+failed+' failed'):''),failed?'error':'success');
585
+ resetBlankBar();
586
+ refreshScreenshots();
587
+ }).catch(function(){
588
+ btn.disabled=false;btn.textContent=prev;
589
+ showToast('Delete failed','error');
590
+ });
591
+ }
592
+ $('#ssScanBlankBtn').addEventListener('click',scanBlankScreenshots);
593
+ $('#ssBlankDeleteBtn').addEventListener('click',deleteBlankScreenshots);
594
+ $('#ssBlankCancelBtn').addEventListener('click',resetBlankBar);
595
+
409
596
  function searchByHash(){
410
597
  var container=$('#ssSearchResult');
411
598
  container.textContent='';
@@ -672,5 +859,245 @@ $('#btnExportLearnings').addEventListener('click',function(){
672
859
  });
673
860
 
674
861
  /* ── Modal ── */
675
- 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
+ }
676
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()});
882
+
883
+ /* ══════════════════════════════════════════════════════════════════
884
+ Screenshot Replay Player — plays a run's per-step screenshots
885
+ as a video. Pulls frames from r.actions[].autoScreenshot|screenshot.
886
+ ══════════════════════════════════════════════════════════════════ */
887
+ var REPLAY={frames:[],idx:0,playing:false,speed:1,timer:null,frameMs:1000};
888
+
889
+ function buildReplayFrames(actions,run){
890
+ var frames=[];
891
+ (actions||[]).forEach(function(a,i){
892
+ var path=a.autoScreenshot||a.screenshot||null;
893
+ frames.push({
894
+ idx:i,
895
+ path:path,
896
+ src:path?'/api/image?path='+encodeURIComponent(path):null,
897
+ type:a.type||'',
898
+ narr:a.narrative||a.type||'',
899
+ duration:a.duration||0,
900
+ success:!!a.success,
901
+ });
902
+ });
903
+ if(run&&!run.success&&run.errorScreenshot){
904
+ frames.push({
905
+ idx:frames.length,
906
+ path:run.errorScreenshot,
907
+ src:'/api/image?path='+encodeURIComponent(run.errorScreenshot),
908
+ type:'failure',
909
+ narr:'Page state at failure',
910
+ duration:0,
911
+ success:false,
912
+ });
913
+ }
914
+ return frames;
915
+ }
916
+
917
+ function openReplay(actions,run){
918
+ REPLAY.frames=buildReplayFrames(actions,run);
919
+ if(!REPLAY.frames.length){showToast&&showToast('No screenshots to replay','warn');return}
920
+ REPLAY.idx=0;REPLAY.playing=false;
921
+ $('#replayModal').classList.add('open');
922
+ $('#replayModal').setAttribute('aria-hidden','false');
923
+ renderReplayFrame();
924
+ // Auto-start playback for the "video" feel
925
+ toggleReplayPlay(true);
926
+ }
927
+
928
+ function closeReplay(){
929
+ stopReplayTimer();
930
+ $('#replayModal').classList.remove('open');
931
+ $('#replayModal').setAttribute('aria-hidden','true');
932
+ // Free image src to avoid lingering downloads
933
+ $('#replayImg').src='';
934
+ }
935
+
936
+ function renderReplayFrame(){
937
+ var f=REPLAY.frames[REPLAY.idx];if(!f)return;
938
+ var modal=$('#replayModal');
939
+ var img=$('#replayImg');
940
+ if(f.src){
941
+ modal.classList.remove('empty');
942
+ if(img.src!==location.origin+f.src&&img.src!==f.src)img.src=f.src;
943
+ }else{
944
+ modal.classList.add('empty');
945
+ img.src='';
946
+ }
947
+ $('#replayStepNum').textContent=(REPLAY.idx+1)+' / '+REPLAY.frames.length;
948
+ $('#replayStepType').textContent=f.type;
949
+ $('#replayStepNarr').textContent=f.narr;
950
+ var pct=REPLAY.frames.length>1?(REPLAY.idx/(REPLAY.frames.length-1))*100:100;
951
+ $('#replayProgressFill').style.width=pct+'%';
952
+ }
953
+
954
+ function scheduleNextReplayFrame(){
955
+ stopReplayTimer();
956
+ if(!REPLAY.playing)return;
957
+ if(REPLAY.idx>=REPLAY.frames.length-1){toggleReplayPlay(false);return}
958
+ // Uniform pacing: 1 frame per second at 1x, scaled by speed.
959
+ var ms=REPLAY.frameMs/REPLAY.speed;
960
+ REPLAY.timer=setTimeout(function(){
961
+ REPLAY.idx++;
962
+ renderReplayFrame();
963
+ scheduleNextReplayFrame();
964
+ },ms);
965
+ }
966
+
967
+ function stopReplayTimer(){if(REPLAY.timer){clearTimeout(REPLAY.timer);REPLAY.timer=null}}
968
+
969
+ function toggleReplayPlay(forceState){
970
+ REPLAY.playing=typeof forceState==='boolean'?forceState:!REPLAY.playing;
971
+ // If we're at the last frame and user hits play, restart from 0
972
+ if(REPLAY.playing&&REPLAY.idx>=REPLAY.frames.length-1){REPLAY.idx=0;renderReplayFrame()}
973
+ var btn=$('#replayPlay');if(btn)btn.innerHTML=REPLAY.playing?'❙❙':'▶';
974
+ if(REPLAY.playing)scheduleNextReplayFrame();else stopReplayTimer();
975
+ }
976
+
977
+ function stepReplay(delta){
978
+ stopReplayTimer();
979
+ REPLAY.idx=Math.max(0,Math.min(REPLAY.frames.length-1,REPLAY.idx+delta));
980
+ renderReplayFrame();
981
+ if(REPLAY.playing)scheduleNextReplayFrame();
982
+ }
983
+
984
+ function setReplaySpeed(s){
985
+ REPLAY.speed=s;
986
+ document.querySelectorAll('.replay-speed-btn').forEach(function(b){
987
+ b.classList.toggle('active',parseFloat(b.dataset.speed)===s);
988
+ });
989
+ if(REPLAY.playing){stopReplayTimer();scheduleNextReplayFrame()}
990
+ }
991
+
992
+ // Wire up controls
993
+ $('#replayPlay').addEventListener('click',function(){toggleReplayPlay()});
994
+ $('#replayPrev').addEventListener('click',function(){stepReplay(-1)});
995
+ $('#replayNext').addEventListener('click',function(){stepReplay(1)});
996
+ $('#replayClose').addEventListener('click',closeReplay);
997
+ document.querySelectorAll('.replay-speed-btn').forEach(function(b){
998
+ b.addEventListener('click',function(){setReplaySpeed(parseFloat(b.dataset.speed))});
999
+ });
1000
+ document.addEventListener('keydown',function(e){
1001
+ if(!$('#replayModal').classList.contains('open'))return;
1002
+ if(e.key==='Escape'){closeReplay()}
1003
+ else if(e.key===' '){e.preventDefault();toggleReplayPlay()}
1004
+ else if(e.key==='ArrowLeft'){stepReplay(-1)}
1005
+ else if(e.key==='ArrowRight'){stepReplay(1)}
1006
+ });
1007
+ // Click on stage advances; click outside the image closes
1008
+ $('#replayModal').addEventListener('click',function(e){
1009
+ if(e.target&&e.target.id==='replayModal')closeReplay();
1010
+ });
1011
+
1012
+ // Expose to the renderer below so the storyline header can wire its button
1013
+ window.openReplay=openReplay;
1014
+
1015
+ /* ══════════════════════════════════════════════════════════════════
1016
+ Network tab — cross-run network query (Investigate › Network)
1017
+ ══════════════════════════════════════════════════════════════════ */
1018
+ function refreshNetwork(){
1019
+ var box=$('#netResults');var empty=$('#networkEmpty');
1020
+ if(!box)return;
1021
+ box.textContent='';
1022
+ box.appendChild(el('div',{style:'padding:20px;text-align:center;color:var(--text3);font-size:11px'},'Loading...'));
1023
+ var runsUrl=S.project?'/api/db/projects/'+S.project+'/runs':'/api/db/runs';
1024
+ api(runsUrl).then(function(runs){
1025
+ if(!Array.isArray(runs)||runs.length===0){
1026
+ box.textContent='';if(empty)empty.style.display='block';return;
1027
+ }
1028
+ if(empty)empty.style.display='none';
1029
+ var top=runs.slice(0,30);
1030
+ var promises=top.map(function(r){
1031
+ return api('/api/db/runs/'+r.id+'/network-logs').catch(function(){return []}).then(function(logs){
1032
+ return {run:r,logs:Array.isArray(logs)?logs:[]};
1033
+ });
1034
+ });
1035
+ Promise.all(promises).then(function(results){renderNetworkResults(box,results)});
1036
+ }).catch(function(){box.textContent='';if(empty)empty.style.display='block'});
1037
+ }
1038
+
1039
+ function renderNetworkResults(box,results){
1040
+ var statusFilter=$('#netStatusFilter')?$('#netStatusFilter').value:'errors';
1041
+ var urlFilter=($('#netUrlFilter')?$('#netUrlFilter').value:'').toLowerCase().trim();
1042
+ var rows=[];
1043
+ results.forEach(function(r){
1044
+ r.logs.forEach(function(n){
1045
+ var s=n.status||0;
1046
+ var keep=true;
1047
+ if(statusFilter==='errors')keep=s>=400||s===0;
1048
+ else if(statusFilter==='slow')keep=(n.duration||0)>=1000;
1049
+ if(keep&&urlFilter&&(n.url||'').toLowerCase().indexOf(urlFilter)<0)keep=false;
1050
+ if(keep)rows.push({run:r.run,n:n});
1051
+ });
1052
+ });
1053
+ box.textContent='';
1054
+ if(rows.length===0){
1055
+ box.appendChild(el('div',{style:'padding:30px;text-align:center;color:var(--text3);font-size:11px'},'No matching network records.'));
1056
+ return;
1057
+ }
1058
+ rows.sort(function(a,b){return (b.n.duration||0)-(a.n.duration||0)});
1059
+ var head=el('div',{className:'net-row net-head'},[
1060
+ el('span',{className:'net-col-run'},'Run'),
1061
+ el('span',{className:'net-col-method'},'Method'),
1062
+ el('span',{className:'net-col-status'},'Status'),
1063
+ el('span',{className:'net-col-url'},'URL'),
1064
+ el('span',{className:'net-col-dur'},'Duration')
1065
+ ]);
1066
+ box.appendChild(head);
1067
+ rows.slice(0,200).forEach(function(rr){
1068
+ var n=rr.n;var s=n.status||0;
1069
+ var sCls=s===0?'s5xx':s<300?'s2xx':s<400?'s3xx':s<500?'s4xx':'s5xx';
1070
+ var mCls=(n.method||'GET').toLowerCase();
1071
+ var row=el('div',{className:'net-row clickable',onclick:function(){
1072
+ showView('investigate');
1073
+ var btn=document.querySelector('.tab-btn[data-tab="runsTabHistory"]');if(btn)btn.click();
1074
+ }},[
1075
+ el('span',{className:'net-col-run'},'#'+rr.run.id),
1076
+ el('span',{className:'net-col-method m-'+mCls},n.method||'GET'),
1077
+ el('span',{className:'net-col-status st-'+sCls},String(s||'ERR')),
1078
+ el('span',{className:'net-col-url',title:n.url||''},n.url||''),
1079
+ el('span',{className:'net-col-dur'},dur(n.duration||0))
1080
+ ]);
1081
+ box.appendChild(row);
1082
+ });
1083
+ if(rows.length>200){
1084
+ box.appendChild(el('div',{style:'padding:10px;text-align:center;color:var(--text3);font-size:11px'},'Showing 200 of '+rows.length+' results'));
1085
+ }
1086
+ }
1087
+
1088
+ (function(){
1089
+ var btn=$('#btnRefreshNetwork');if(btn)btn.addEventListener('click',refreshNetwork);
1090
+ var sel=$('#netStatusFilter');if(sel)sel.addEventListener('change',refreshNetwork);
1091
+ var inp=$('#netUrlFilter');
1092
+ if(inp){
1093
+ var deb;
1094
+ inp.addEventListener('input',function(){clearTimeout(deb);deb=setTimeout(refreshNetwork,200)});
1095
+ }
1096
+ // Lazy: only fetch first time the Network tab is clicked
1097
+ var netTabBtn=document.querySelector('.tab-btn[data-tab="investigateTabNetwork"]');
1098
+ if(netTabBtn){
1099
+ netTabBtn.addEventListener('click',function(){
1100
+ if(!netTabBtn.dataset.loaded){netTabBtn.dataset.loaded='1';refreshNetwork()}
1101
+ });
1102
+ }
1103
+ })();