@matware/e2e-runner 1.3.1 → 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 (47) hide show
  1. package/.claude-plugin/marketplace.json +4 -4
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.md +110 -21
  4. package/agents/test-creator.md +4 -2
  5. package/agents/test-improver.md +5 -3
  6. package/bin/cli.js +80 -17
  7. package/package.json +3 -2
  8. package/skills/e2e-testing/SKILL.md +3 -2
  9. package/skills/e2e-testing/references/action-types.md +22 -4
  10. package/skills/e2e-testing/references/test-json-format.md +23 -0
  11. package/src/actions.js +170 -14
  12. package/src/config.js +6 -0
  13. package/src/dashboard.js +135 -4
  14. package/src/db.js +11 -0
  15. package/src/mcp-tools.js +8 -2
  16. package/src/module-analysis.js +247 -0
  17. package/src/module-resolver.js +35 -2
  18. package/src/narrate.js +14 -1
  19. package/src/pool-manager.js +46 -1
  20. package/src/pool.js +177 -20
  21. package/src/runner.js +77 -10
  22. package/src/visual-diff.js +69 -0
  23. package/src/websocket.js +14 -3
  24. package/src/wizard.js +184 -0
  25. package/templates/build-dashboard.js +3 -0
  26. package/templates/dashboard/js/api.js +60 -3
  27. package/templates/dashboard/js/init.js +46 -0
  28. package/templates/dashboard/js/keyboard.js +8 -7
  29. package/templates/dashboard/js/quicksearch.js +277 -0
  30. package/templates/dashboard/js/state.js +61 -7
  31. package/templates/dashboard/js/toast.js +1 -1
  32. package/templates/dashboard/js/view-live.js +235 -42
  33. package/templates/dashboard/js/view-runs.js +379 -37
  34. package/templates/dashboard/js/view-tests.js +157 -16
  35. package/templates/dashboard/js/view-tools.js +234 -0
  36. package/templates/dashboard/js/view-watch.js +2 -2
  37. package/templates/dashboard/js/websocket.js +33 -3
  38. package/templates/dashboard/styles/base.css +489 -53
  39. package/templates/dashboard/styles/components.css +719 -84
  40. package/templates/dashboard/styles/view-live.css +459 -78
  41. package/templates/dashboard/styles/view-runs.css +779 -177
  42. package/templates/dashboard/styles/view-tests.css +440 -77
  43. package/templates/dashboard/styles/view-tools.css +206 -0
  44. package/templates/dashboard/styles/view-watch.css +198 -41
  45. package/templates/dashboard/template.html +354 -56
  46. package/templates/dashboard.html +5173 -711
  47. 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
  ]);
@@ -1,57 +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]}S.screencastTest=null;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
 
8
- // Screencast state
9
- S.screencastTest=null;
10
-
11
- $('#screencastSelect').addEventListener('change',function(){
12
- S.screencastTest=this.value||null;
13
- var img=$('#screencastImg'),ph=$('#screencastPlaceholder');
14
- if(S.screencastTest){img.style.display='block';ph.style.display='none';img.src=''}
15
- else{img.style.display='none';ph.style.display='flex'}
16
- });
17
-
18
- function updateScreencastSelect(){
19
- var sel=$('#screencastSelect'),panel=$('#screencastPanel');
20
- var runningTests=[];
21
- for(var k in S.liveRuns){var r=S.liveRuns[k];for(var n in r.tests){if(n!=='__error'&&r.tests[n].status==='running')runningTests.push(n)}}
22
- // Show panel if any run is active
23
- var anyActive=false;for(var k2 in S.liveRuns)if(S.liveRuns[k2].on)anyActive=true;
24
- panel.style.display=anyActive?'':'none';
25
- // Rebuild options
26
- var prev=sel.value;
27
- while(sel.options.length>1)sel.remove(1);
28
- runningTests.forEach(function(n){var o=document.createElement('option');o.value=n;o.textContent=n;sel.appendChild(o)});
29
- // Auto-select first running test if nothing selected
30
- if(!S.screencastTest&&runningTests.length>0){S.screencastTest=runningTests[0];sel.value=S.screencastTest;$('#screencastImg').style.display='block';$('#screencastPlaceholder').style.display='none'}
31
- else if(S.screencastTest&&runningTests.indexOf(S.screencastTest)===-1){
32
- // Current test finished — pick next running or clear
33
- if(runningTests.length>0){S.screencastTest=runningTests[0];sel.value=S.screencastTest}
34
- else{S.screencastTest=null;sel.value='';$('#screencastImg').style.display='none';$('#screencastPlaceholder').style.display='flex';$('#screencastPlaceholder').textContent='No running tests'}
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;
35
169
  }
36
- else{sel.value=S.screencastTest||''}
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();
37
203
  }
38
204
 
39
205
  function renderLive(){
40
206
  var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
41
207
  var runs=S.liveRuns;var runIds=Object.keys(runs);
42
208
 
43
- 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
+ }
44
215
 
45
- navLive.style.display='';liveEmpty.style.display='none';panel.classList.add('active');
216
+ liveEmpty.style.display='none';panel.classList.add('active');
46
217
 
47
218
  var gTotal=0,gCompleted=0,gPassed=0,gFailed=0,gActive=0,gRunning=false,gDone=true;
48
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});
49
220
 
50
221
  var badgeActive=0;
51
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++})});
52
- $('#liveBadge').textContent=gRunning?badgeActive:gCompleted;
53
- $('#liveBadge').style.background=gRunning?'var(--purple-dim)':gFailed>0?'var(--red-dim)':'var(--green-dim)';
54
- $('#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);
55
227
 
56
228
  $('#liveTotal').textContent=gTotal;$('#livePass').textContent=gPassed;$('#liveFail').textContent=gFailed;$('#liveActive').textContent=gActive;
57
229
  $('#liveProgressFill').style.width=(gTotal>0?gCompleted/gTotal*100:0)+'%';
@@ -146,18 +318,26 @@ function renderLive(){
146
318
  ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
147
319
  }
148
320
 
149
- // Screencast focus indicator
150
- var scFocusBadge=null;
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;
151
324
  if(t.status==='running'){
152
- var isFocused=S.screencastTest===name;
153
- scFocusBadge=el('span',{className:'sc-focus-badge'+(isFocused?' active':''),title:'Watch this test',onclick:function(e){e.stopPropagation();S.screencastTest=name;$('#screencastSelect').value=name;$('#screencastImg').style.display='block';$('#screencastPlaceholder').style.display='none';renderLive()}},'\uD83C\uDFA5');
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
+ ]);
154
333
  }
155
334
  var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
156
335
  var poolBadge=t.poolUrl?el('span',{className:'pool-badge'},t.poolUrl.replace('ws://','').replace('wss://','')):null;
157
- 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},[
158
338
  el('div',{className:'lt-name'},[
159
339
  t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
160
- document.createTextNode(' '+name),scFocusBadge,serialBadge,poolBadge,summaryEl
340
+ document.createTextNode(' '+name),scWatchBtn,serialBadge,poolBadge,summaryEl
161
341
  ]),
162
342
  el('div',{className:'lt-meta'},meta),stepsEl
163
343
  ]);
@@ -177,5 +357,18 @@ function renderLive(){
177
357
  });
178
358
  grid.appendChild(testGrid);
179
359
  });
180
- updateScreencastSelect();
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);
181
374
  }