@matware/e2e-runner 1.3.0 → 1.3.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.
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Visual Diff Engine — zero-dependency pixel comparison for PNG screenshots.
3
+ *
4
+ * Uses Node's built-in zlib for PNG inflate/deflate. No external image libs.
5
+ *
6
+ * Comparison algorithm:
7
+ * 1. Decode both PNGs to raw RGBA buffers
8
+ * 2. For each pixel, compute YIQ perceptual color distance
9
+ * 3. Pixels exceeding the threshold are marked as different
10
+ * 4. Anti-aliasing pixels (edge pixels) can be optionally ignored
11
+ * 5. Masked regions are skipped entirely
12
+ * 6. Output: diff stats + optional diff PNG (red overlay on changed pixels)
13
+ */
14
+
15
+ import fs from 'fs';
16
+ import zlib from 'zlib';
17
+
18
+ // ── PNG Decoder ───────────────────────────────────────────────────────────────
19
+
20
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
21
+
22
+ function readChunks(buf) {
23
+ const chunks = [];
24
+ let offset = 8; // skip signature
25
+ while (offset < buf.length) {
26
+ const length = buf.readUInt32BE(offset);
27
+ const type = buf.toString('ascii', offset + 4, offset + 8);
28
+ const data = buf.subarray(offset + 8, offset + 8 + length);
29
+ chunks.push({ type, data });
30
+ offset += 12 + length; // 4 length + 4 type + data + 4 crc
31
+ }
32
+ return chunks;
33
+ }
34
+
35
+ function decodePNG(filePath) {
36
+ const buf = fs.readFileSync(filePath);
37
+
38
+ if (buf.compare(PNG_SIGNATURE, 0, 8, 0, 8) !== 0) {
39
+ throw new Error(`Not a valid PNG file: ${filePath}`);
40
+ }
41
+
42
+ const chunks = readChunks(buf);
43
+ const ihdr = chunks.find(c => c.type === 'IHDR');
44
+ if (!ihdr) throw new Error('Missing IHDR chunk');
45
+
46
+ const width = ihdr.data.readUInt32BE(0);
47
+ const height = ihdr.data.readUInt32BE(4);
48
+ const bitDepth = ihdr.data[8];
49
+ const colorType = ihdr.data[9];
50
+
51
+ if (bitDepth !== 8) throw new Error(`Unsupported bit depth: ${bitDepth} (only 8-bit supported)`);
52
+ if (colorType !== 2 && colorType !== 6) {
53
+ throw new Error(`Unsupported color type: ${colorType} (only RGB=2 and RGBA=6 supported)`);
54
+ }
55
+
56
+ const hasAlpha = colorType === 6;
57
+ const bpp = hasAlpha ? 4 : 3; // bytes per pixel in the raw data
58
+
59
+ // Concatenate all IDAT chunks and decompress
60
+ const idatData = Buffer.concat(chunks.filter(c => c.type === 'IDAT').map(c => c.data));
61
+ const inflated = zlib.inflateSync(idatData);
62
+
63
+ // Reverse scanline filters → raw RGBA
64
+ const stride = width * bpp + 1; // +1 for filter byte
65
+ const rgba = Buffer.alloc(width * height * 4);
66
+
67
+ // Previous row for filter operations (starts as zeros)
68
+ const prevRow = Buffer.alloc(width * bpp);
69
+
70
+ for (let y = 0; y < height; y++) {
71
+ const filterType = inflated[y * stride];
72
+ const rowStart = y * stride + 1;
73
+ const row = Buffer.alloc(width * bpp);
74
+
75
+ for (let x = 0; x < width * bpp; x++) {
76
+ const raw = inflated[rowStart + x];
77
+ const a = x >= bpp ? row[x - bpp] : 0; // left
78
+ const b = prevRow[x]; // above
79
+ const c = x >= bpp ? prevRow[x - bpp] : 0; // upper-left
80
+
81
+ switch (filterType) {
82
+ case 0: row[x] = raw; break; // None
83
+ case 1: row[x] = (raw + a) & 0xff; break; // Sub
84
+ case 2: row[x] = (raw + b) & 0xff; break; // Up
85
+ case 3: row[x] = (raw + ((a + b) >> 1)) & 0xff; break; // Average
86
+ case 4: row[x] = (raw + paethPredictor(a, b, c)) & 0xff; break; // Paeth
87
+ default: throw new Error(`Unknown PNG filter type: ${filterType}`);
88
+ }
89
+ }
90
+
91
+ // Copy to RGBA output
92
+ for (let x = 0; x < width; x++) {
93
+ const srcIdx = x * bpp;
94
+ const dstIdx = (y * width + x) * 4;
95
+ rgba[dstIdx] = row[srcIdx]; // R
96
+ rgba[dstIdx + 1] = row[srcIdx + 1]; // G
97
+ rgba[dstIdx + 2] = row[srcIdx + 2]; // B
98
+ rgba[dstIdx + 3] = hasAlpha ? row[srcIdx + 3] : 255; // A
99
+ }
100
+
101
+ row.copy(prevRow);
102
+ }
103
+
104
+ return { width, height, data: rgba };
105
+ }
106
+
107
+ function paethPredictor(a, b, c) {
108
+ const p = a + b - c;
109
+ const pa = Math.abs(p - a);
110
+ const pb = Math.abs(p - b);
111
+ const pc = Math.abs(p - c);
112
+ if (pa <= pb && pa <= pc) return a;
113
+ if (pb <= pc) return b;
114
+ return c;
115
+ }
116
+
117
+ // ── PNG Encoder (minimal, for diff images) ────────────────────────────────────
118
+
119
+ function encodePNG(width, height, rgba) {
120
+ // Build unfiltered scanlines (filter byte 0 = None for each row)
121
+ const rawLines = Buffer.alloc(height * (1 + width * 4));
122
+ for (let y = 0; y < height; y++) {
123
+ rawLines[y * (1 + width * 4)] = 0; // filter: None
124
+ rgba.copy(rawLines, y * (1 + width * 4) + 1, y * width * 4, (y + 1) * width * 4);
125
+ }
126
+
127
+ const compressed = zlib.deflateSync(rawLines);
128
+
129
+ // Build PNG file
130
+ const chunks = [];
131
+
132
+ // Signature
133
+ chunks.push(PNG_SIGNATURE);
134
+
135
+ // IHDR
136
+ const ihdr = Buffer.alloc(13);
137
+ ihdr.writeUInt32BE(width, 0);
138
+ ihdr.writeUInt32BE(height, 4);
139
+ ihdr[8] = 8; // bit depth
140
+ ihdr[9] = 6; // color type: RGBA
141
+ ihdr[10] = 0; // compression
142
+ ihdr[11] = 0; // filter
143
+ ihdr[12] = 0; // interlace
144
+ chunks.push(makeChunk('IHDR', ihdr));
145
+
146
+ // IDAT
147
+ chunks.push(makeChunk('IDAT', compressed));
148
+
149
+ // IEND
150
+ chunks.push(makeChunk('IEND', Buffer.alloc(0)));
151
+
152
+ return Buffer.concat(chunks);
153
+ }
154
+
155
+ function makeChunk(type, data) {
156
+ const buf = Buffer.alloc(12 + data.length);
157
+ buf.writeUInt32BE(data.length, 0);
158
+ buf.write(type, 4, 4, 'ascii');
159
+ data.copy(buf, 8);
160
+ buf.writeUInt32BE(crc32(buf.subarray(4, 8 + data.length)), 8 + data.length);
161
+ return buf;
162
+ }
163
+
164
+ // CRC-32 (PNG uses CRC-32/ISO-HDLC)
165
+ const crcTable = (() => {
166
+ const table = new Uint32Array(256);
167
+ for (let n = 0; n < 256; n++) {
168
+ let c = n;
169
+ for (let k = 0; k < 8; k++) {
170
+ c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
171
+ }
172
+ table[n] = c;
173
+ }
174
+ return table;
175
+ })();
176
+
177
+ function crc32(buf) {
178
+ let crc = 0xffffffff;
179
+ for (let i = 0; i < buf.length; i++) {
180
+ crc = crcTable[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
181
+ }
182
+ return (crc ^ 0xffffffff) >>> 0;
183
+ }
184
+
185
+ // ── Pixel Comparison ──────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * Computes perceptual color distance using YIQ color space.
189
+ * YIQ weights human perception: luminance matters most, chrominance less.
190
+ */
191
+ function colorDelta(img1, img2, idx1, idx2) {
192
+ const r1 = img1[idx1], g1 = img1[idx1 + 1], b1 = img1[idx1 + 2], a1 = img1[idx1 + 3];
193
+ const r2 = img2[idx2], g2 = img2[idx2 + 1], b2 = img2[idx2 + 2], a2 = img2[idx2 + 3];
194
+
195
+ // Blend alpha to white background for fair comparison
196
+ const rb1 = blend(r1, a1), gb1 = blend(g1, a1), bb1 = blend(b1, a1);
197
+ const rb2 = blend(r2, a2), gb2 = blend(g2, a2), bb2 = blend(b2, a2);
198
+
199
+ const y = rgb2y(rb1, gb1, bb1) - rgb2y(rb2, gb2, bb2);
200
+ const i = rgb2i(rb1, gb1, bb1) - rgb2i(rb2, gb2, bb2);
201
+ const q = rgb2q(rb1, gb1, bb1) - rgb2q(rb2, gb2, bb2);
202
+
203
+ // Weight: Y (luminance) is most important, I and Q (chrominance) less
204
+ return 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
205
+ }
206
+
207
+ function blend(c, a) {
208
+ return 255 + (c - 255) * (a / 255);
209
+ }
210
+ function rgb2y(r, g, b) { return r * 0.29889531 + g * 0.58662247 + b * 0.11448223; }
211
+ function rgb2i(r, g, b) { return r * 0.59597799 - g * 0.27417610 - b * 0.32180189; }
212
+ function rgb2q(r, g, b) { return r * 0.21147017 - g * 0.52261711 + b * 0.31114694; }
213
+
214
+ /**
215
+ * Checks if a pixel is likely an anti-aliased edge pixel.
216
+ * Looks at immediate neighbors — if few are similar and many differ, it's an edge.
217
+ */
218
+ function isAntiAliased(img, x, y, width, height, otherImg) {
219
+ const idx = (y * width + x) * 4;
220
+ let similar = 0;
221
+ let different = 0;
222
+
223
+ for (let dy = -1; dy <= 1; dy++) {
224
+ for (let dx = -1; dx <= 1; dx++) {
225
+ if (dx === 0 && dy === 0) continue;
226
+ const nx = x + dx;
227
+ const ny = y + dy;
228
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
229
+ const nIdx = (ny * width + nx) * 4;
230
+ const delta = colorDelta(img, img, idx, nIdx);
231
+ if (delta < 15) similar++;
232
+ else different++;
233
+ }
234
+ }
235
+
236
+ // Anti-aliased: pixel differs from most neighbors (it's on an edge)
237
+ // AND the corresponding pixel in the other image is also on an edge
238
+ if (different < 3) return false;
239
+
240
+ const otherIdx = idx;
241
+ let otherSimilar = 0;
242
+ let otherDifferent = 0;
243
+ for (let dy = -1; dy <= 1; dy++) {
244
+ for (let dx = -1; dx <= 1; dx++) {
245
+ if (dx === 0 && dy === 0) continue;
246
+ const nx = x + dx;
247
+ const ny = y + dy;
248
+ if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue;
249
+ const nIdx = (ny * width + nx) * 4;
250
+ const delta = colorDelta(otherImg, otherImg, otherIdx, nIdx);
251
+ if (delta < 15) otherSimilar++;
252
+ else otherDifferent++;
253
+ }
254
+ }
255
+
256
+ return otherDifferent >= 3;
257
+ }
258
+
259
+ // ── Public API ────────────────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * Compares two PNG images and returns diff statistics + optional diff image.
263
+ *
264
+ * @param {string} baselinePath — Path to the "golden" reference PNG
265
+ * @param {string} currentPath — Path to the current/new screenshot PNG
266
+ * @param {object} [options] — Comparison options
267
+ * @param {number} [options.threshold=0.1] — Color distance threshold (0-1). Lower = stricter.
268
+ * @param {boolean} [options.includeAntiAlias=false] — Count anti-aliased pixels as different
269
+ * @param {Array} [options.maskRegions=[]] — Regions to ignore: [{ x, y, width, height }]
270
+ * @param {string} [options.diffOutputPath] — If set, writes a diff PNG highlighting changes
271
+ * @param {object} [options.diffColor] — Diff highlight color: { r, g, b } (default: red)
272
+ * @returns {{ totalPixels, differentPixels, diffPercentage, matchPercentage, dimensions, passed, diffImagePath? }}
273
+ */
274
+ export function compareImages(baselinePath, currentPath, options = {}) {
275
+ const {
276
+ threshold = 0.1,
277
+ includeAntiAlias = false,
278
+ maskRegions = [],
279
+ diffOutputPath = null,
280
+ diffColor = { r: 255, g: 0, b: 60 },
281
+ } = options;
282
+
283
+ const baseline = decodePNG(baselinePath);
284
+ const current = decodePNG(currentPath);
285
+
286
+ // Images must have the same dimensions for pixel comparison
287
+ if (baseline.width !== current.width || baseline.height !== current.height) {
288
+ return {
289
+ totalPixels: baseline.width * baseline.height,
290
+ differentPixels: baseline.width * baseline.height,
291
+ diffPercentage: 1,
292
+ matchPercentage: 0,
293
+ dimensions: {
294
+ baseline: { width: baseline.width, height: baseline.height },
295
+ current: { width: current.width, height: current.height },
296
+ },
297
+ dimensionMismatch: true,
298
+ passed: false,
299
+ diffImagePath: null,
300
+ };
301
+ }
302
+
303
+ const { width, height } = baseline;
304
+ const totalPixels = width * height;
305
+ const maxDelta = 35215 * threshold * threshold; // max YIQ distance scaled by threshold
306
+ let differentPixels = 0;
307
+
308
+ // Build mask lookup for O(1) checks
309
+ const isMasked = buildMaskLookup(maskRegions, width, height);
310
+
311
+ // Diff image buffer (semi-transparent overlay of original with red diff pixels)
312
+ const diffData = diffOutputPath ? Buffer.alloc(width * height * 4) : null;
313
+
314
+ for (let y = 0; y < height; y++) {
315
+ for (let x = 0; x < width; x++) {
316
+ const idx = (y * width + x) * 4;
317
+
318
+ // Skip masked regions
319
+ if (isMasked(x, y)) {
320
+ if (diffData) {
321
+ // Show masked areas as semi-transparent gray
322
+ diffData[idx] = 128;
323
+ diffData[idx + 1] = 128;
324
+ diffData[idx + 2] = 128;
325
+ diffData[idx + 3] = 80;
326
+ }
327
+ continue;
328
+ }
329
+
330
+ const delta = colorDelta(baseline.data, current.data, idx, idx);
331
+
332
+ if (delta > maxDelta) {
333
+ // Check anti-aliasing
334
+ if (!includeAntiAlias &&
335
+ (isAntiAliased(baseline.data, x, y, width, height, current.data) ||
336
+ isAntiAliased(current.data, x, y, width, height, baseline.data))) {
337
+ // Anti-aliased pixel — show as faded yellow in diff
338
+ if (diffData) {
339
+ diffData[idx] = 255;
340
+ diffData[idx + 1] = 255;
341
+ diffData[idx + 2] = 0;
342
+ diffData[idx + 3] = 40;
343
+ }
344
+ continue;
345
+ }
346
+
347
+ differentPixels++;
348
+ if (diffData) {
349
+ // Highlight difference in diff color
350
+ diffData[idx] = diffColor.r;
351
+ diffData[idx + 1] = diffColor.g;
352
+ diffData[idx + 2] = diffColor.b;
353
+ diffData[idx + 3] = 220;
354
+ }
355
+ } else if (diffData) {
356
+ // Matching pixel — show dimmed version of current image
357
+ diffData[idx] = current.data[idx];
358
+ diffData[idx + 1] = current.data[idx + 1];
359
+ diffData[idx + 2] = current.data[idx + 2];
360
+ diffData[idx + 3] = 60; // very transparent
361
+ }
362
+ }
363
+ }
364
+
365
+ const diffPercentage = totalPixels > 0 ? differentPixels / totalPixels : 0;
366
+ let diffImagePath = null;
367
+
368
+ if (diffData && diffOutputPath) {
369
+ const pngBuf = encodePNG(width, height, diffData);
370
+ fs.writeFileSync(diffOutputPath, pngBuf);
371
+ diffImagePath = diffOutputPath;
372
+ }
373
+
374
+ return {
375
+ totalPixels,
376
+ differentPixels,
377
+ diffPercentage,
378
+ matchPercentage: 1 - diffPercentage,
379
+ dimensions: { width, height },
380
+ passed: diffPercentage === 0,
381
+ diffImagePath,
382
+ };
383
+ }
384
+
385
+ /**
386
+ * Compares two images with a pass/fail threshold.
387
+ *
388
+ * @param {string} baselinePath — Path to golden reference
389
+ * @param {string} currentPath — Path to current screenshot
390
+ * @param {number} [maxDiffPercent=0.02] — Max allowed diff percentage (0.02 = 2%)
391
+ * @param {object} [options] — Same options as compareImages()
392
+ * @returns {{ passed, diffPercentage, differentPixels, totalPixels, diffImagePath?, error? }}
393
+ */
394
+ export function assertVisualMatch(baselinePath, currentPath, maxDiffPercent = 0.02, options = {}) {
395
+ if (!fs.existsSync(baselinePath)) {
396
+ return { passed: false, error: `Baseline not found: ${baselinePath}`, diffPercentage: 1 };
397
+ }
398
+ if (!fs.existsSync(currentPath)) {
399
+ return { passed: false, error: `Current screenshot not found: ${currentPath}`, diffPercentage: 1 };
400
+ }
401
+
402
+ const result = compareImages(baselinePath, currentPath, options);
403
+ result.passed = result.diffPercentage <= maxDiffPercent;
404
+ result.maxDiffPercent = maxDiffPercent;
405
+ return result;
406
+ }
407
+
408
+ /**
409
+ * Builds a fast mask lookup function from region definitions.
410
+ * @param {Array<{x,y,width,height}>} regions
411
+ * @param {number} imgWidth
412
+ * @param {number} imgHeight
413
+ * @returns {(x: number, y: number) => boolean}
414
+ */
415
+ function buildMaskLookup(regions, imgWidth, imgHeight) {
416
+ if (!regions || regions.length === 0) return () => false;
417
+
418
+ // For small numbers of regions, direct check is fast enough
419
+ if (regions.length <= 4) {
420
+ return (x, y) => {
421
+ for (const r of regions) {
422
+ if (x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height) return true;
423
+ }
424
+ return false;
425
+ };
426
+ }
427
+
428
+ // For many regions, build a bitfield
429
+ const mask = new Uint8Array(Math.ceil(imgWidth * imgHeight / 8));
430
+ for (const r of regions) {
431
+ const x0 = Math.max(0, r.x);
432
+ const y0 = Math.max(0, r.y);
433
+ const x1 = Math.min(imgWidth, r.x + r.width);
434
+ const y1 = Math.min(imgHeight, r.y + r.height);
435
+ for (let y = y0; y < y1; y++) {
436
+ for (let x = x0; x < x1; x++) {
437
+ const bit = y * imgWidth + x;
438
+ mask[bit >> 3] |= (1 << (bit & 7));
439
+ }
440
+ }
441
+ }
442
+ return (x, y) => {
443
+ const bit = y * imgWidth + x;
444
+ return (mask[bit >> 3] & (1 << (bit & 7))) !== 0;
445
+ };
446
+ }
@@ -6,6 +6,8 @@ function triggerRun(suite,projectId){
6
6
  if(suite)body.suite=suite;
7
7
  if(projectId)body.projectId=projectId;
8
8
  else if(S.project)body.projectId=S.project;
9
+ var scToggle=$('#screencastToggle');
10
+ if(scToggle&&scToggle.checked)body.screencast=true;
9
11
  fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
10
12
  }
11
13
 
@@ -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,10 +1,41 @@
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()}
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
5
  function dismissLiveRun(rid){delete S.liveRuns[rid];renderLive()}
6
6
  $('#liveClearBtn').addEventListener('click',clearFinishedLiveRuns);
7
7
 
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'}
35
+ }
36
+ else{sel.value=S.screencastTest||''}
37
+ }
38
+
8
39
  function renderLive(){
9
40
  var panel=$('#livePanel'),grid=$('#liveTests'),navLive=$('#navLive'),liveEmpty=$('#liveEmpty');
10
41
  var runs=S.liveRuns;var runIds=Object.keys(runs);
@@ -115,12 +146,18 @@ function renderLive(){
115
146
  ssEl=el('div',{className:'lt-screenshots'},[toggle,ssGridEl]);
116
147
  }
117
148
 
149
+ // Screencast focus indicator
150
+ var scFocusBadge=null;
151
+ 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');
154
+ }
118
155
  var serialBadge=t.serial?el('span',{className:'serial-badge'},'Serial'):null;
119
156
  var poolBadge=t.poolUrl?el('span',{className:'pool-badge'},t.poolUrl.replace('ws://','').replace('wss://','')):null;
120
157
  var card=el('div',{className:'live-test '+t.status+(isCollapsed?' collapsed':'')},[
121
158
  el('div',{className:'lt-name'},[
122
159
  t.status==='running'?el('span',{className:'spinner'}):el('span',{className:'lt-icon',style:iconColor},iconText),
123
- document.createTextNode(' '+name),serialBadge,poolBadge,summaryEl
160
+ document.createTextNode(' '+name),scFocusBadge,serialBadge,poolBadge,summaryEl
124
161
  ]),
125
162
  el('div',{className:'lt-meta'},meta),stepsEl
126
163
  ]);
@@ -140,4 +177,5 @@ function renderLive(){
140
177
  });
141
178
  grid.appendChild(testGrid);
142
179
  });
180
+ updateScreencastSelect();
143
181
  }