@matware/e2e-runner 1.3.0 → 1.5.0

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 (56) hide show
  1. package/.claude-plugin/marketplace.json +37 -6
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/LICENSE +190 -0
  4. package/README.md +151 -527
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +5 -3
  7. package/bin/cli.js +84 -20
  8. package/commands/capture.md +45 -0
  9. package/package.json +3 -2
  10. package/skills/e2e-testing/SKILL.md +3 -2
  11. package/skills/e2e-testing/references/action-types.md +22 -4
  12. package/skills/e2e-testing/references/test-json-format.md +23 -0
  13. package/src/actions.js +321 -14
  14. package/src/ai-generate.js +81 -0
  15. package/src/app-pool.js +339 -0
  16. package/src/config.js +131 -7
  17. package/src/dashboard.js +209 -11
  18. package/src/db.js +74 -7
  19. package/src/index.js +6 -4
  20. package/src/learner-sqlite.js +154 -0
  21. package/src/learner.js +70 -3
  22. package/src/mcp-tools.js +259 -34
  23. package/src/module-analysis.js +247 -0
  24. package/src/module-resolver.js +35 -2
  25. package/src/narrate.js +42 -1
  26. package/src/pool-manager.js +68 -17
  27. package/src/pool.js +464 -37
  28. package/src/reporter.js +4 -1
  29. package/src/runner.js +410 -63
  30. package/src/visual-diff.js +515 -0
  31. package/src/websocket.js +14 -3
  32. package/src/wizard.js +184 -0
  33. package/templates/build-dashboard.js +3 -0
  34. package/templates/dashboard/js/api.js +62 -3
  35. package/templates/dashboard/js/init.js +46 -0
  36. package/templates/dashboard/js/keyboard.js +8 -7
  37. package/templates/dashboard/js/quicksearch.js +277 -0
  38. package/templates/dashboard/js/state.js +61 -7
  39. package/templates/dashboard/js/toast.js +1 -1
  40. package/templates/dashboard/js/utils.js +20 -0
  41. package/templates/dashboard/js/view-live.js +240 -9
  42. package/templates/dashboard/js/view-runs.js +540 -94
  43. package/templates/dashboard/js/view-tests.js +157 -16
  44. package/templates/dashboard/js/view-tools.js +234 -0
  45. package/templates/dashboard/js/view-watch.js +2 -2
  46. package/templates/dashboard/js/websocket.js +36 -0
  47. package/templates/dashboard/styles/base.css +489 -53
  48. package/templates/dashboard/styles/components.css +719 -77
  49. package/templates/dashboard/styles/view-live.css +463 -59
  50. package/templates/dashboard/styles/view-runs.css +793 -155
  51. package/templates/dashboard/styles/view-tests.css +440 -77
  52. package/templates/dashboard/styles/view-tools.css +206 -0
  53. package/templates/dashboard/styles/view-watch.css +198 -41
  54. package/templates/dashboard/template.html +369 -56
  55. package/templates/dashboard.html +5375 -901
  56. package/templates/docker-compose-lightpanda.yml +7 -0
@@ -1,26 +1,78 @@
1
1
  /* ── Global State ── */
2
2
  var S={
3
- ws:null,project:null,view:'watch',selectedRun:null,
3
+ ws:null,project:null,view:'overview',selectedRun:null,
4
4
  liveRuns:{},liveCollapsed:new Set(),liveSSOpen:new Set(),
5
5
  runFilter:{status:'all',search:''},
6
6
  lastLearningsData:null,
7
- highlightedRunIdx:-1
7
+ highlightedRunIdx:-1,
8
+ testsSearch:'',testsExpanded:new Set(),
9
+ /* Screencast selection: {runId, name} | null. Composite to avoid name
10
+ collisions between concurrent runs. When null, the live preview
11
+ auto-follows the most recent frame from any running test. */
12
+ screencastSel:null,
13
+ /* Auto-follow latest frame when no test is explicitly pinned. */
14
+ screencastAuto:true,
15
+ /* {runId, name} of the test whose frame is currently shown in auto mode. */
16
+ screencastLast:null,
17
+ /* Ring buffer of recent frames for the filmstrip: {src, name, ts}. */
18
+ screencastFilm:[],
19
+ _filmTs:0
8
20
  };
21
+ function screencastKey(s){return s?(s.runId+'::'+s.name):null}
9
22
 
10
- /* ── Navigation ── */
23
+ /* ── Section labels (used in top bar breadcrumb) ── */
24
+ var VIEW_LABELS={
25
+ overview:'Overview',
26
+ live:'Live',
27
+ run:'Run',
28
+ investigate:'Investigate',
29
+ insights:'Insights',
30
+ tools:'Tools'
31
+ };
32
+
33
+ /* ── Navigation ──
34
+ Nav items may carry an optional data-tab attribute that activates
35
+ a sub-tab inside the destination view (used by promoted sub-items
36
+ like Suites/Modules/Variables under "Test Definitions"). */
11
37
  $$('.nav-item').forEach(function(n){
12
38
  n.addEventListener('click',function(){
13
- showView(n.dataset.view);
39
+ showView(n.dataset.view,n.dataset.tab);
14
40
  });
15
41
  });
16
- function showView(v){
42
+ function showView(v,subTab){
17
43
  S.view=v;
18
- $$('.nav-item').forEach(function(n){n.classList.toggle('active',n.dataset.view===v)});
44
+ // Match active state by (view + optional tab) so sub-items don't all light
45
+ // up just because they share the same parent view.
46
+ $$('.nav-item').forEach(function(n){
47
+ var sameView=n.dataset.view===v;
48
+ var sameTab=(n.dataset.tab||'')===(subTab||'');
49
+ n.classList.toggle('active',sameView&&sameTab);
50
+ });
19
51
  $$('.view').forEach(function(x){x.classList.remove('active')});
20
52
  var viewEl=$('#view-'+v);
21
53
  if(viewEl)viewEl.classList.add('active');
22
- if(v==='watch'&&typeof startWatchPolling==='function')startWatchPolling();
54
+ // Activate the requested sub-tab inside the view
55
+ if(subTab&&viewEl){
56
+ var btn=viewEl.querySelector('.tab-btn[data-tab="'+subTab+'"]');
57
+ if(btn&&!btn.classList.contains('active'))btn.click();
58
+ }
59
+ if(v==='overview'&&typeof startWatchPolling==='function')startWatchPolling();
23
60
  else if(typeof stopWatchPolling==='function')stopWatchPolling();
61
+ // Re-render Live when switching to it so empty/active panel state matches reality
62
+ if(v==='live'&&typeof renderLive==='function')renderLive();
63
+ updateBreadcrumb();
64
+ }
65
+
66
+ /* ── Breadcrumb (top bar) ── */
67
+ function updateBreadcrumb(subLabel){
68
+ var bc=$('#topbarBreadcrumb');if(!bc)return;
69
+ var label=VIEW_LABELS[S.view]||S.view;
70
+ bc.textContent='';
71
+ bc.appendChild(el('span',{className:'topbar-section'},label));
72
+ if(subLabel){
73
+ bc.appendChild(el('span',{className:'topbar-sep'},'›'));
74
+ bc.appendChild(el('span',{className:'topbar-subsection'},subLabel));
75
+ }
24
76
  }
25
77
 
26
78
  /* ── Inner Tabs ── */
@@ -34,6 +86,8 @@ function initTabs(){
34
86
  container.querySelectorAll('.tab-pane').forEach(function(p){p.classList.remove('active')});
35
87
  var pane=container.querySelector('#'+btn.dataset.tab);
36
88
  if(pane)pane.classList.add('active');
89
+ var label=(btn.firstChild&&btn.firstChild.nodeType===3?btn.firstChild.nodeValue:btn.textContent).trim();
90
+ updateBreadcrumb(label);
37
91
  });
38
92
  });
39
93
  });
@@ -18,7 +18,7 @@ function showToast(message,type,timeout){
18
18
  function showEnrichedToast(message,type){
19
19
  var container=$('#toastContainer');
20
20
  var icons={success:'\u2714',error:'\u2718',info:'\u2139'};
21
- var t=el('div',{className:'toast clickable '+type,onclick:function(){showView('runs');var lb=$('#runsTabLearnings');if(lb)lb.click()}},[
21
+ var t=el('div',{className:'toast clickable '+type,onclick:function(){showView('insights')}},[
22
22
  el('span',null,icons[type]||''),
23
23
  el('span',null,message)
24
24
  ]);
@@ -162,6 +162,26 @@ function createTriggerBadge(source){
162
162
  return badge;
163
163
  }
164
164
 
165
+ function createDriverBadge(driver){
166
+ if(!driver)return document.createTextNode('--');
167
+ var labels={browserless:'Browserless',cdp:'CDP',steel:'Steel',auto:'Auto'};
168
+ var colors={browserless:'var(--accent)',cdp:'var(--purple)',steel:'var(--amber)'};
169
+ var icons={browserless:'\u{1F310}',cdp:'\u{1F50C}',steel:'\u{1F6E1}'};
170
+ // Handle multi-driver (e.g. "browserless,steel")
171
+ var parts=driver.split(',');
172
+ if(parts.length>1){
173
+ var wrap=el('span',{style:'display:inline-flex;gap:4px'});
174
+ parts.forEach(function(d){wrap.appendChild(createDriverBadge(d.trim()))});
175
+ return wrap;
176
+ }
177
+ var d=driver.trim();
178
+ var badge=el('span',{className:'driver-badge drv-'+d,style:'color:'+(colors[d]||'var(--text3)')},[
179
+ el('span',{className:'drv-icon'},icons[d]||'\u2699'),
180
+ document.createTextNode(labels[d]||d)
181
+ ]);
182
+ return badge;
183
+ }
184
+
165
185
  /* ── Pool Distribution Summary ── */
166
186
  var POOL_COLORS=['#6366f1','#22d3ee','#f59e0b','#10b981','#ef4444','#8b5cf6','#ec4899','#14b8a6'];
167
187
  function buildPoolDistribution(tests){
@@ -1,26 +1,229 @@
1
1
  /* ══════════════════════════════════════════════════════════════════
2
2
  Live Execution View
3
3
  ══════════════════════════════════════════════════════════════════ */
4
- function clearFinishedLiveRuns(){for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}renderLive()}
5
- function dismissLiveRun(rid){delete S.liveRuns[rid];renderLive()}
4
+ function clearFinishedLiveRuns(){
5
+ for(var k in S.liveRuns){if(S.liveRuns[k].done||!S.liveRuns[k].on)delete S.liveRuns[k]}
6
+ S.screencastSel=null;S.screencastLast=null;
7
+ var im=$('#screencastImg');if(im){im.src='';im.style.display='none';var vp=im.closest('.screencast-viewport');if(vp)vp.classList.remove('has-frame')}
8
+ if(typeof resetFilmstrip==='function')resetFilmstrip();
9
+ renderLive();
10
+ }
11
+ function dismissLiveRun(rid){
12
+ if(S.screencastSel&&S.screencastSel.runId===rid)S.screencastSel=null;
13
+ if(S.screencastLast&&S.screencastLast.runId===rid)S.screencastLast=null;
14
+ delete S.liveRuns[rid];renderLive();
15
+ }
6
16
  $('#liveClearBtn').addEventListener('click',clearFinishedLiveRuns);
7
17
 
18
+ /* Pick a test to screencast. Composite key {runId, name} so concurrent
19
+ runs with the same test name never collide into a single <img>. */
20
+ function selectScreencast(runId,name){
21
+ if(S.screencastSel&&S.screencastSel.runId===runId&&S.screencastSel.name===name){
22
+ // Same test clicked again — unselect (stop watching)
23
+ S.screencastSel=null;
24
+ }else{
25
+ S.screencastSel={runId:runId,name:name};
26
+ // Clear the previous frame so we don't briefly show another test's last frame
27
+ var im=$('#screencastImg');if(im){im.src='';im.style.display='none'}
28
+ }
29
+ renderLive();
30
+ }
31
+ function stopScreencast(){S.screencastSel=null;renderLive()}
32
+
33
+ var _scStopBtn=$('#screencastStopBtn');
34
+ if(_scStopBtn)_scStopBtn.addEventListener('click',stopScreencast);
35
+
36
+ /* Reset the live preview + filmstrip (used when the watched test changes so
37
+ two tests' frames never pile up in the same feed). */
38
+ function clearScreencastFrame(){
39
+ var im=$('#screencastImg');
40
+ if(im){im.src='';im.style.display='none';var vp=im.closest('.screencast-viewport');if(vp)vp.classList.remove('has-frame')}
41
+ S.screencastLast=null;
42
+ if(typeof resetFilmstrip==='function')resetFilmstrip();
43
+ }
44
+
45
+ /* Test chooser: "auto" follows the latest test; a specific value pins the feed
46
+ to that one test only. */
47
+ (function(){
48
+ var sel=$('#screencastTestSelect');if(!sel)return;
49
+ sel.addEventListener('change',function(){
50
+ if(this.value==='auto'){
51
+ S.screencastSel=null;S.screencastAuto=true;
52
+ }else{
53
+ var i=this.value.indexOf('::');
54
+ S.screencastSel={runId:this.value.slice(0,i),name:this.value.slice(i+2)};
55
+ }
56
+ clearScreencastFrame();
57
+ renderLive();
58
+ });
59
+ })();
60
+
61
+ /* Keep the chooser's options in sync with the live tests — but only rebuild
62
+ when the set actually changes, so it doesn't clobber the open dropdown. */
63
+ function syncScreencastSelect(){
64
+ var sel=$('#screencastTestSelect');if(!sel)return;
65
+ var items=[];
66
+ Object.keys(S.liveRuns).forEach(function(rid){
67
+ var run=S.liveRuns[rid];
68
+ Object.keys(run.tests||{}).forEach(function(n){
69
+ if(n==='__error')return;
70
+ items.push({key:rid+'::'+n,runId:rid,name:n,status:run.tests[n].status});
71
+ });
72
+ });
73
+ items.sort(function(a,b){return (a.status==='running'?0:1)-(b.status==='running'?0:1)});
74
+ var curKey=S.screencastSel?(S.screencastSel.runId+'::'+S.screencastSel.name):'auto';
75
+ var sig=curKey+'|'+items.map(function(o){return o.key+':'+o.status}).join(',');
76
+ if(sel.getAttribute('data-sig')===sig)return;
77
+ sel.setAttribute('data-sig',sig);
78
+ sel.textContent='';
79
+ sel.appendChild(el('option',{value:'auto'},'Auto — latest test'));
80
+ items.forEach(function(o){
81
+ var mark=o.status==='running'?'● ':o.status==='passed'?'✓ ':o.status==='failed'?'✕ ':'· ';
82
+ sel.appendChild(el('option',{value:o.key},mark+o.name));
83
+ });
84
+ sel.value=curKey;
85
+ if(sel.value!==curKey)sel.value='auto';
86
+ }
87
+
88
+ /* Click the live preview → open it full-size in the lightbox. */
89
+ (function(){
90
+ var im=$('#screencastImg');
91
+ if(im)im.addEventListener('click',function(){
92
+ if(im.src&&im.src.indexOf('data:')===0&&typeof openModal==='function')openModal(im.src);
93
+ });
94
+ })();
95
+
96
+ /* Filmstrip — keep a small ring buffer of recent frames, throttled so a
97
+ high-rate screencast doesn't thrash the DOM. Each thumb opens full-size. */
98
+ var SC_FILM_MAX=24, SC_FILM_THROTTLE=600;
99
+ function pushFilmFrame(src,name,runId){
100
+ var now=Date.now();
101
+ if(now-(S._filmTs||0)<SC_FILM_THROTTLE)return;
102
+ S._filmTs=now;
103
+ S.screencastFilm.push({src:src,name:name||'',runId:runId||null,ts:now});
104
+ while(S.screencastFilm.length>SC_FILM_MAX)S.screencastFilm.shift();
105
+ renderFilmstrip();
106
+ }
107
+ /* Render the bottom band: recent frames in fixed slots that fit the width —
108
+ no horizontal scroll, so the strip stays put while frames pass through it.
109
+ Newest is the rightmost (marked LIVE). Any thumb opens full-size on click. */
110
+ function renderFilmstrip(){
111
+ var strip=$('#screencastFilm');if(!strip)return;
112
+ strip.textContent='';
113
+ var film=S.screencastFilm||[];
114
+ // Pinned to one test → show only its frames (safety net against mixing).
115
+ if(S.screencastSel){
116
+ film=film.filter(function(f){return f.runId===S.screencastSel.runId&&f.name===S.screencastSel.name});
117
+ }
118
+ if(!film.length){
119
+ strip.appendChild(el('div',{className:'screencast-film-empty'},
120
+ anyLiveRunning()?'Waiting for first frame…':'No frames yet'));
121
+ return;
122
+ }
123
+ // Fit as many fixed-width slots as the band can show without scrolling.
124
+ var h=strip.clientHeight||150, gap=10;
125
+ var thumbW=Math.max(120,(h-28)*1.6);
126
+ var avail=(strip.clientWidth||900)-32;
127
+ var fit=Math.max(1,Math.floor((avail+gap)/(thumbW+gap)));
128
+ var start=Math.max(0,film.length-fit);
129
+ var shown=film.slice(start);
130
+ var live=anyLiveRunning();
131
+ shown.forEach(function(f,i){
132
+ var isLast=(i===shown.length-1);
133
+ var img=document.createElement('img');img.src=f.src;img.alt=f.name;img.loading='lazy';
134
+ var thumb=el('div',{className:'film-thumb'+(isLast&&live?' is-live':''),
135
+ title:(f.name||'frame')+' — click to enlarge',
136
+ onclick:(function(s){return function(){if(typeof openModal==='function')openModal(s)}})(f.src)},
137
+ [img,el('span',{className:'film-idx'},String(start+i+1))]);
138
+ strip.appendChild(thumb);
139
+ });
140
+ }
141
+ function resetFilmstrip(){S.screencastFilm=[];S._filmTs=0;renderFilmstrip()}
142
+
143
+ function scTestStatus(sel){
144
+ if(!sel)return null;
145
+ var r=S.liveRuns[sel.runId];if(!r)return 'gone';
146
+ var t=r.tests&&r.tests[sel.name];if(!t)return 'gone';
147
+ return t.status;
148
+ }
149
+
150
+ function updateScreencastUI(){
151
+ var panel=$('#screencastPanel');
152
+ var anyActive=false;for(var k in S.liveRuns)if(S.liveRuns[k].on)anyActive=true;
153
+ var ctxEl=$('#screencastContext'),img=$('#screencastImg'),ph=$('#screencastPlaceholder'),stopBtn=$('#screencastStopBtn');
154
+ var hasFrame=img&&img.src&&img.src.indexOf('data:')===0;
155
+
156
+ var pinned=S.screencastSel;
157
+ var auto=!pinned&&S.screencastAuto!==false;
158
+
159
+ // Show panel while a run is active, a test is pinned, or a frame is on screen.
160
+ panel.style.display=(anyActive||pinned||hasFrame)?'':'none';
161
+
162
+ // Idle: nothing pinned, auto off (or nothing ever shown) and no activity.
163
+ if(!pinned&&!auto&&!hasFrame){
164
+ if(ctxEl){ctxEl.textContent='';ctxEl.className='screencast-context idle'}
165
+ if(stopBtn)stopBtn.style.display='none';
166
+ if(img)img.style.display='none';
167
+ if(ph){ph.style.display='flex';ph.textContent=anyActive?'Waiting for first frame…':'No tests running'}
168
+ return;
169
+ }
170
+
171
+ // Subject: the pinned test, or (auto) the test of the last frame shown.
172
+ var sel=pinned||S.screencastLast;
173
+ var status=pinned?scTestStatus(pinned):(anyActive?'running':'ended');
174
+ var run=sel&&S.liveRuns[sel.runId];
175
+ var proj=(run&&(run.project||(run.cwd?run.cwd.split('/').pop():'')))||'';
176
+
177
+ if(ctxEl){
178
+ ctxEl.textContent='';
179
+ if(proj)ctxEl.appendChild(el('span',{className:'sc-ctx-proj'},proj));
180
+ if(sel)ctxEl.appendChild(el('span',{className:'sc-ctx-name'},sel.name));
181
+ var pillTxt,pillCls;
182
+ if(pinned){
183
+ pillTxt=status==='running'?'WATCHING':status==='passed'?'ENDED · PASSED':status==='failed'?'ENDED · FAILED':'GONE';
184
+ pillCls=status==='running'?'running':status==='passed'?'passed':status==='failed'?'failed':'gone';
185
+ }else{
186
+ pillTxt=anyActive?'AUTO · LIVE':'AUTO · LAST FRAME';
187
+ pillCls=anyActive?'running':'gone';
188
+ }
189
+ ctxEl.appendChild(el('span',{className:'sc-ctx-pill '+pillCls},pillTxt));
190
+ ctxEl.className='screencast-context '+(((pinned&&status==='running')||(!pinned&&anyActive))?'active':'ended');
191
+ }
192
+ // Stop button only when pinned — it returns to auto-follow.
193
+ if(stopBtn){stopBtn.style.display=pinned?'':'none';stopBtn.title='Stop watching (return to auto-follow)'}
194
+
195
+ if(hasFrame)img.style.display='block';
196
+ if(ph){
197
+ if(hasFrame){ph.style.display='none'}
198
+ else{ph.style.display='flex';ph.textContent=anyActive?'Waiting for first frame…':'No frames yet'}
199
+ }
200
+ // Keep the bottom band in sync (empty hint when no frames yet).
201
+ if(typeof renderFilmstrip==='function')renderFilmstrip();
202
+ if(typeof syncScreencastSelect==='function')syncScreencastSelect();
203
+ }
204
+
8
205
  function renderLive(){
9
206
  var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
10
207
  var runs=S.liveRuns;var runIds=Object.keys(runs);
11
208
 
12
- if(runIds.length===0){panel.classList.remove('active');navLive.style.display='none';liveEmpty.style.display='block';$('#liveClearBtn').style.display='none';return}
209
+ if(runIds.length===0){
210
+ panel.classList.remove('active');liveEmpty.style.display='block';$('#liveClearBtn').style.display='none';
211
+ var lb=$('#liveBadge');lb.textContent='0';lb.className='badge idle';
212
+ syncTopbarLive(false,0,0);
213
+ return;
214
+ }
13
215
 
14
- navLive.style.display='';liveEmpty.style.display='none';panel.classList.add('active');
216
+ liveEmpty.style.display='none';panel.classList.add('active');
15
217
 
16
218
  var gTotal=0,gCompleted=0,gPassed=0,gFailed=0,gActive=0,gRunning=false,gDone=true;
17
219
  runIds.forEach(function(rid){var r=runs[rid];gTotal+=r.total;gCompleted+=r.completed;gPassed+=r.passed;gFailed+=r.failed;gActive+=r.active;if(r.on)gRunning=true;if(!r.done)gDone=false});
18
220
 
19
221
  var badgeActive=0;
20
222
  runIds.forEach(function(rid){var r=runs[rid];Object.keys(r.tests).forEach(function(n){if(n!=='__error'&&r.tests[n].status==='running')badgeActive++})});
21
- $('#liveBadge').textContent=gRunning?badgeActive:gCompleted;
22
- $('#liveBadge').style.background=gRunning?'var(--purple-dim)':gFailed>0?'var(--red-dim)':'var(--green-dim)';
23
- $('#liveBadge').style.color=gRunning?'var(--purple)':gFailed>0?'var(--red)':'var(--green)';
223
+ var lb2=$('#liveBadge');
224
+ lb2.textContent=gRunning?badgeActive:gCompleted;
225
+ lb2.className='badge '+(gRunning?'running':gFailed>0?'failed':'passed');
226
+ syncTopbarLive(gRunning,badgeActive,gFailed);
24
227
 
25
228
  $('#liveTotal').textContent=gTotal;$('#livePass').textContent=gPassed;$('#liveFail').textContent=gFailed;$('#liveActive').textContent=gActive;
26
229
  $('#liveProgressFill').style.width=(gTotal>0?gCompleted/gTotal*100:0)+'%';
@@ -115,12 +318,26 @@ function renderLive(){
115
318
  ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
116
319
  }
117
320
 
321
+ // Screencast watch button \u2014 only on running tests. Composite-key aware.
322
+ var isWatched=S.screencastSel&&S.screencastSel.runId===rid&&S.screencastSel.name===name;
323
+ var scWatchBtn=null;
324
+ if(t.status==='running'){
325
+ scWatchBtn=el('button',{
326
+ className:'sc-watch-btn'+(isWatched?' active':''),
327
+ title:isWatched?'Stop watching':'Watch this test',
328
+ onclick:(function(_rid,_name){return function(e){e.stopPropagation();selectScreencast(_rid,_name)}})(rid,name)
329
+ },[
330
+ el('span',{className:'sc-eye'},isWatched?'\u25C9':'\u25CB'),
331
+ el('span',{className:'sc-watch-label'},isWatched?'WATCHING':'WATCH')
332
+ ]);
333
+ }
118
334
  var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
119
335
  var poolBadge=t.poolUrl?el('span',{className:'pool-badge'},t.poolUrl.replace('ws://','').replace('wss://','')):null;
120
- var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
336
+ var cardCls='live-test '+t.status+(isCollapsed?' collapsed':'')+(isWatched?' sc-watching':'');
337
+ var card=el('div',{className:cardCls},[
121
338
  el('div',{className:'lt-name'},[
122
339
  t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
123
- document.createTextNode(' '+name),serialBadge,poolBadge,summaryEl
340
+ document.createTextNode(' '+name),scWatchBtn,serialBadge,poolBadge,summaryEl
124
341
  ]),
125
342
  el('div',{className:'lt-meta'},meta),stepsEl
126
343
  ]);
@@ -140,4 +357,18 @@ function renderLive(){
140
357
  });
141
358
  grid.appendChild(testGrid);
142
359
  });
360
+ updateScreencastUI();
361
+ }
362
+
363
+ /* Sync the top bar Live shortcut: greyed when idle, pulsing purple when running */
364
+ function syncTopbarLive(running,activeCount,failedCount){
365
+ var pill=$('#topbarLive');if(!pill)return;
366
+ var count=$('#topbarLiveCount');
367
+ pill.classList.remove('idle','running','failed','passed');
368
+ if(running){pill.classList.add('running');count.textContent=activeCount}
369
+ else if(failedCount>0){pill.classList.add('failed');count.textContent=failedCount}
370
+ else if(activeCount===0&&!running){pill.classList.add('idle');count.textContent='0'}
371
+ else{pill.classList.add('passed');count.textContent=activeCount||0}
372
+ // Mirror the running count to the telemetry strip
373
+ if(typeof renderRunningTelemetry==='function')renderRunningTelemetry(running?activeCount:0);
143
374
  }