plum-e2e 2.4.12 → 2.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 (50) hide show
  1. package/backend/_scaffold/utils/browser.ts +47 -2
  2. package/backend/_scaffold/utils/hooks.ts +25 -2
  3. package/backend/config/scripts/run-tests.js +35 -13
  4. package/backend/logs/runner-cmr8o2x3n0000mg01410tpuck.log +20 -0
  5. package/backend/logs/runner-cmr90hvcx0000n2016edtmrtv.log +43 -0
  6. package/backend/logs/runner-cmr90i7h20001n201g2wg6v3n.log +43 -0
  7. package/backend/logs/runner-cmr90iilm0002n201mmisva95.log +43 -0
  8. package/backend/logs/runner-cmr90j70r0003n201xz8525dz.log +20 -0
  9. package/backend/logs/runner-cmr90yup70000qp017ianc2gp.log +43 -0
  10. package/backend/logs/runner-cmr90zbtm0001qp01u9tfdkh1.log +20 -0
  11. package/backend/logs/runner-cmr91a3mw0000tb01414rd1df.log +20 -0
  12. package/backend/logs/runner-cmr91dhla0001tb01jp7thjnl.log +20 -0
  13. package/backend/prisma/migrations/20260706000000_add_report_logs/migration.sql +2 -0
  14. package/backend/prisma/schema.prisma +1 -0
  15. package/backend/routes/node.routes.js +42 -4
  16. package/backend/services/reportService.js +24 -4
  17. package/backend/services/runnerService.js +11 -1
  18. package/backend/websockets/socketHandler.js +162 -16
  19. package/bin/plum.js +2 -2
  20. package/frontend/package.json +1 -1
  21. package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
  22. package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
  23. package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
  24. package/frontend/src/lib/components/ui/Badge.svelte +1 -1
  25. package/frontend/src/lib/components/ui/Button.svelte +1 -1
  26. package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
  27. package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
  28. package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
  29. package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
  30. package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
  31. package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
  32. package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
  33. package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
  34. package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
  35. package/frontend/src/lib/constants.js +14 -0
  36. package/frontend/src/lib/stores/runner.js +5 -3
  37. package/frontend/src/lib/styles/tokens.css +26 -0
  38. package/frontend/src/lib/utils/format.js +74 -0
  39. package/frontend/src/routes/+page.svelte +3 -3
  40. package/frontend/src/routes/login/+page.svelte +1 -1
  41. package/frontend/src/routes/reports/+page.svelte +3 -3
  42. package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
  43. package/frontend/src/routes/reports/live/+page.svelte +281 -283
  44. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
  45. package/frontend/src/routes/settings/+page.svelte +6 -6
  46. package/frontend/src/routes/setup/+page.svelte +1 -1
  47. package/frontend/src/routes/test-repository/+page.svelte +5 -5
  48. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +27 -151
  49. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
  50. package/package.json +1 -1
@@ -16,51 +16,55 @@
16
16
  -->
17
17
 
18
18
  <script>
19
- import { onMount, afterUpdate, onDestroy } from 'svelte';
19
+ import { afterUpdate, onDestroy } from 'svelte';
20
20
  import { goto } from '$app/navigation';
21
21
  import { fly, fade } from 'svelte/transition';
22
22
  import { runnerState, cancelRun, activeCronJobs } from '$lib/stores/runner';
23
23
  import { reportUrl } from '$lib/api/reports';
24
+ import { REDIRECT_DELAY_MS, ALL_TESTS_LABEL } from '$lib/constants';
25
+ import BackLink from '$lib/components/ui/BackLink.svelte';
26
+ import Badge from '$lib/components/ui/Badge.svelte';
24
27
 
25
28
  let terminalEl;
26
- let laneEls = {};
29
+ let laneTerminalEl;
27
30
  let redirectTimer = null;
28
- let redirectCountdown = 3;
31
+ let redirectCountdown = REDIRECT_DELAY_MS / 1000;
29
32
  let countdownInterval = null;
33
+ let activeTab = null;
30
34
 
31
35
  $: state = $runnerState;
32
36
  $: isMulti = state.lanes.length > 0;
33
37
  $: cronJobs = Object.keys($activeCronJobs);
34
38
  $: anyCronRunning = cronJobs.length > 0;
35
39
 
36
- let wasAnyCronRunning = false;
40
+ // Keep active tab pointing at a valid lane
37
41
  $: {
38
- if (wasAnyCronRunning && !anyCronRunning && !state.running && !state.testCompleted) {
39
- goto('/reports');
42
+ if (state.lanes.length === 0) {
43
+ activeTab = null;
44
+ } else if (!state.lanes.find((l) => l.id === activeTab)) {
45
+ activeTab = state.lanes[0].id;
40
46
  }
41
- wasAnyCronRunning = anyCronRunning;
42
47
  }
48
+ $: activeLane = state.lanes.find((l) => l.id === activeTab) ?? null;
49
+
50
+ let wasAnyCronRunning = false;
43
51
 
44
- // Auto-scroll terminals
45
52
  afterUpdate(() => {
46
53
  if (terminalEl) terminalEl.scrollTop = terminalEl.scrollHeight;
47
- for (const el of Object.values(laneEls)) {
48
- if (el) el.scrollTop = el.scrollHeight;
54
+ if (laneTerminalEl) laneTerminalEl.scrollTop = laneTerminalEl.scrollHeight;
55
+ if (wasAnyCronRunning && !anyCronRunning && !state.running && !state.testCompleted) {
56
+ goto('/reports');
49
57
  }
58
+ wasAnyCronRunning = anyCronRunning;
50
59
  });
51
60
 
52
- // Watch for test completion → auto-navigate to report
53
61
  $: if (state.testCompleted && state.latestReportId && !redirectTimer) {
54
- redirectCountdown = 3;
62
+ redirectCountdown = REDIRECT_DELAY_MS / 1000;
55
63
  countdownInterval = setInterval(() => {
56
64
  redirectCountdown--;
57
- if (redirectCountdown <= 0) {
58
- clearInterval(countdownInterval);
59
- }
65
+ if (redirectCountdown <= 0) clearInterval(countdownInterval);
60
66
  }, 1000);
61
- redirectTimer = setTimeout(() => {
62
- goto(reportUrl(state.latestReportId));
63
- }, 3000);
67
+ redirectTimer = setTimeout(() => goto(reportUrl(state.latestReportId)), REDIRECT_DELAY_MS);
64
68
  }
65
69
 
66
70
  onDestroy(() => {
@@ -73,40 +77,13 @@
73
77
  if (countdownInterval) clearInterval(countdownInterval);
74
78
  goto(reportUrl(state.latestReportId));
75
79
  }
76
-
77
- function handleCancel() {
78
- cancelRun();
79
- }
80
-
81
- function laneStatusColor(status) {
82
- if (status === 'done') return 'var(--pass)';
83
- if (status === 'error') return 'var(--fail)';
84
- return 'var(--accent)';
85
- }
86
80
  </script>
87
81
 
88
82
  <svelte:head><title>Live Run — Plum</title></svelte:head>
89
83
 
90
- <div class="back-row">
91
- <a href="/reports" class="back-link">
92
- <svg
93
- width="14"
94
- height="14"
95
- viewBox="0 0 24 24"
96
- fill="none"
97
- stroke="currentColor"
98
- stroke-width="2"
99
- stroke-linecap="round"
100
- >
101
- <line x1="19" y1="12" x2="5" y2="12" />
102
- <polyline points="12 19 5 12 12 5" />
103
- </svg>
104
- Reports
105
- </a>
106
- </div>
84
+ <BackLink href="/reports" label="Reports" />
107
85
 
108
86
  {#if !state.running && !state.testCompleted && !anyCronRunning}
109
- <!-- Nothing running and no completed test -->
110
87
  <div class="idle-state">
111
88
  <div class="idle-icon">
112
89
  <svg
@@ -128,11 +105,8 @@
128
105
  <a href="/reports" class="idle-link">View past reports →</a>
129
106
  </div>
130
107
  {:else if anyCronRunning && !state.running && !state.testCompleted}
131
- <!-- Scheduled run(s) in progress -->
132
108
  <div class="cron-running-state">
133
- <div class="cron-running-icon">
134
- <span class="cron-pulse-dot"></span>
135
- </div>
109
+ <div class="cron-running-icon"><span class="cron-pulse-dot"></span></div>
136
110
  <h2>Scheduled {cronJobs.length === 1 ? 'run' : 'runs'} in progress</h2>
137
111
  <div class="cron-task-list">
138
112
  {#each cronJobs as name}
@@ -142,7 +116,7 @@
142
116
  <p>The report will open automatically when the run finishes.</p>
143
117
  </div>
144
118
  {:else}
145
- <!-- Run header -->
119
+ <!-- ── Run header ── -->
146
120
  <div
147
121
  class="run-header"
148
122
  class:header-pass={state.status === 'pass'}
@@ -150,42 +124,11 @@
150
124
  >
151
125
  <div class="header-left">
152
126
  {#if state.running}
153
- <span class="live-badge">
154
- <span class="live-dot"></span>
155
- Live
156
- </span>
127
+ <span class="live-badge"><span class="live-dot"></span>Live</span>
157
128
  {:else if state.status === 'pass'}
158
- <span class="result-badge pass-badge">
159
- <svg
160
- width="12"
161
- height="12"
162
- viewBox="0 0 24 24"
163
- fill="none"
164
- stroke="currentColor"
165
- stroke-width="2.5"
166
- stroke-linecap="round"
167
- stroke-linejoin="round"
168
- >
169
- <polyline points="20 6 9 17 4 12" />
170
- </svg>
171
- Passed
172
- </span>
129
+ <Badge variant="pass">Passed</Badge>
173
130
  {:else}
174
- <span class="result-badge fail-badge">
175
- <svg
176
- width="12"
177
- height="12"
178
- viewBox="0 0 24 24"
179
- fill="none"
180
- stroke="currentColor"
181
- stroke-width="2.5"
182
- stroke-linecap="round"
183
- stroke-linejoin="round"
184
- >
185
- <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
186
- </svg>
187
- Failed
188
- </span>
131
+ <Badge variant="fail">Failed</Badge>
189
132
  {/if}
190
133
 
191
134
  {#if state.currentRun}
@@ -194,7 +137,7 @@
194
137
  <span class="run-title-label">{state.currentRun.runTitle}</span>
195
138
  <span class="run-sep">·</span>
196
139
  {/if}
197
- <span class="run-tag-label">{state.currentRun.tag || 'all tests'}</span>
140
+ <span class="run-tag-label">{state.currentRun.tag || ALL_TESTS_LABEL}</span>
198
141
  <span class="run-sep">·</span>
199
142
  <span class="run-detail"
200
143
  >{state.currentRun.workers} worker{state.currentRun.workers !== 1 ? 's' : ''}</span
@@ -210,7 +153,7 @@
210
153
  </div>
211
154
 
212
155
  {#if state.running}
213
- <button class="cancel-btn" on:click={handleCancel}>
156
+ <button class="cancel-btn" on:click={cancelRun}>
214
157
  <svg
215
158
  width="12"
216
159
  height="12"
@@ -230,7 +173,7 @@
230
173
  {/if}
231
174
  </div>
232
175
 
233
- <!-- Completion overlay -->
176
+ <!-- ── Completion bar ── -->
234
177
  {#if state.testCompleted && state.latestReportId}
235
178
  <div
236
179
  class="completion-bar"
@@ -248,10 +191,8 @@
248
191
  stroke="currentColor"
249
192
  stroke-width="2.5"
250
193
  stroke-linecap="round"
251
- stroke-linejoin="round"
194
+ stroke-linejoin="round"><polyline points="20 6 9 17 4 12" /></svg
252
195
  >
253
- <polyline points="20 6 9 17 4 12" />
254
- </svg>
255
196
  All tests passed
256
197
  {:else}
257
198
  <svg
@@ -263,9 +204,8 @@
263
204
  stroke-width="2.5"
264
205
  stroke-linecap="round"
265
206
  stroke-linejoin="round"
207
+ ><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg
266
208
  >
267
- <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
268
- </svg>
269
209
  Some tests failed
270
210
  {/if}
271
211
  </div>
@@ -282,83 +222,133 @@
282
222
  stroke-width="2"
283
223
  stroke-linecap="round"
284
224
  stroke-linejoin="round"
225
+ ><line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" /></svg
285
226
  >
286
- <line x1="5" y1="12" x2="19" y2="12" /><polyline points="12 5 19 12 12 19" />
287
- </svg>
288
227
  </button>
289
228
  </div>
290
229
  </div>
291
230
  {/if}
292
231
 
293
- <!-- Terminals -->
232
+ <!-- ── Multi-runner tabs ── -->
294
233
  {#if isMulti}
295
- <!-- Multi-runner: one terminal per lane -->
296
- <div
297
- class="lanes-grid"
298
- style="grid-template-columns: repeat({Math.min(state.lanes.length, 2)}, 1fr)"
299
- >
234
+ <div class="lane-tabs">
300
235
  {#each state.lanes as lane}
301
- <div class="lane-terminal">
302
- <div class="lane-header">
303
- <span
304
- class="lane-dot"
305
- style="background:{laneStatusColor(lane.status)}; {lane.status === 'running'
306
- ? 'animation: dotPulse 1.6s ease-in-out infinite'
307
- : ''}"
308
- ></span>
309
- <span class="lane-name">{lane.name}</span>
310
- {#if lane.testCount}
311
- <span class="lane-count">{lane.testCount} test{lane.testCount !== 1 ? 's' : ''}</span>
312
- {/if}
313
- <span class="lane-status-text" style="color:{laneStatusColor(lane.status)}">
314
- {lane.status === 'done' ? 'Done' : lane.status === 'error' ? 'Failed' : 'Running'}
315
- </span>
236
+ <button
237
+ class="lane-tab"
238
+ class:active={activeTab === lane.id}
239
+ on:click={() => (activeTab = lane.id)}
240
+ >
241
+ <span
242
+ class="tab-dot"
243
+ class:tab-dot-running={lane.status === 'running'}
244
+ class:tab-dot-done={lane.status === 'done'}
245
+ class:tab-dot-error={lane.status === 'error'}
246
+ ></span>
247
+ {lane.name}
248
+ {#if lane.testCount}
249
+ <span class="tab-count">{lane.testCount}</span>
250
+ {/if}
251
+ </button>
252
+ {/each}
253
+ </div>
254
+ {/if}
255
+
256
+ <!-- ── Dual-stream view ── -->
257
+ <div class="run-view" class:no-top-radius={isMulti}>
258
+ <!-- Screenshot panel -->
259
+ <div class="screenshot-panel">
260
+ {#if isMulti}
261
+ {#if activeLane?.latestScreenshot}
262
+ {#key activeLane.latestScreenshot.data}
263
+ <img
264
+ src="data:image/jpeg;base64,{activeLane.latestScreenshot.data}"
265
+ class="live-shot"
266
+ alt="Live browser view"
267
+ in:fade={{ duration: 120 }}
268
+ />
269
+ {/key}
270
+ <div class="step-overlay">
271
+ <span class="step-keyword">Step</span>
272
+ <span class="step-name">{activeLane.latestScreenshot.stepName}</span>
316
273
  </div>
317
- <div class="terminal-bar">
318
- <span class="dot red"></span>
319
- <span class="dot yellow"></span>
320
- <span class="dot green"></span>
274
+ {:else}
275
+ <div class="awaiting-state">
276
+ <svg
277
+ width="32"
278
+ height="32"
279
+ viewBox="0 0 24 24"
280
+ fill="none"
281
+ stroke="currentColor"
282
+ stroke-width="1.2"
283
+ stroke-linecap="round"
284
+ stroke-linejoin="round"
285
+ opacity="0.25"
286
+ >
287
+ <rect x="2" y="5" width="20" height="14" rx="2" />
288
+ <circle cx="12" cy="12" r="3" />
289
+ </svg>
290
+ <span>{activeLane?.status === 'running' ? 'Awaiting stream...' : 'No stream...'}</span>
321
291
  </div>
322
- <pre class="terminal" bind:this={laneEls[lane.id]}>{lane.logs ||
323
- '(waiting for output…)'}</pre>
292
+ {/if}
293
+ {:else if state.latestScreenshot}
294
+ {#key state.latestScreenshot.data}
295
+ <img
296
+ src="data:image/jpeg;base64,{state.latestScreenshot.data}"
297
+ class="live-shot"
298
+ alt="Live browser view"
299
+ in:fade={{ duration: 120 }}
300
+ />
301
+ {/key}
302
+ <div class="step-overlay">
303
+ <span class="step-keyword">Step</span>
304
+ <span class="step-name">{state.latestScreenshot.stepName}</span>
324
305
  </div>
325
- {/each}
306
+ {:else}
307
+ <div class="awaiting-state">
308
+ <svg
309
+ width="32"
310
+ height="32"
311
+ viewBox="0 0 24 24"
312
+ fill="none"
313
+ stroke="currentColor"
314
+ stroke-width="1.2"
315
+ stroke-linecap="round"
316
+ stroke-linejoin="round"
317
+ opacity="0.25"
318
+ >
319
+ <rect x="2" y="5" width="20" height="14" rx="2" />
320
+ <circle cx="12" cy="12" r="3" />
321
+ </svg>
322
+ <span>{state.running ? 'Awaiting stream...' : 'No stream'}</span>
323
+ </div>
324
+ {/if}
326
325
  </div>
327
- {:else}
328
- <!-- Single terminal -->
329
- <div class="terminal-wrap">
326
+
327
+ <!-- Terminal panel -->
328
+ <div class="terminal-panel">
330
329
  <div class="terminal-bar">
331
330
  <span class="dot red"></span>
332
331
  <span class="dot yellow"></span>
333
332
  <span class="dot green"></span>
334
333
  <span class="terminal-label">
335
- {state.running ? 'Running…' : 'Finished'}
334
+ {#if isMulti}
335
+ {activeLane?.name ?? 'Runner'}
336
+ {:else}
337
+ {state.running ? 'Running…' : 'Finished'}
338
+ {/if}
336
339
  </span>
337
340
  </div>
338
- <pre class="terminal" bind:this={terminalEl}>{state.output}</pre>
341
+ {#if isMulti}
342
+ <pre class="terminal" bind:this={laneTerminalEl}>{activeLane?.logs ||
343
+ '(waiting for output…)'}</pre>
344
+ {:else}
345
+ <pre class="terminal" bind:this={terminalEl}>{state.output}</pre>
346
+ {/if}
339
347
  </div>
340
- {/if}
348
+ </div>
341
349
  {/if}
342
350
 
343
351
  <style>
344
- .back-row {
345
- margin-bottom: 1.5rem;
346
- }
347
-
348
- .back-link {
349
- display: inline-flex;
350
- align-items: center;
351
- gap: 0.35rem;
352
- font-size: 0.8125rem;
353
- color: var(--text-muted);
354
- text-decoration: none;
355
- transition: color var(--duration-fast);
356
- }
357
-
358
- .back-link:hover {
359
- color: var(--text);
360
- }
361
-
362
352
  /* ── Idle state ── */
363
353
  .idle-state {
364
354
  display: flex;
@@ -369,31 +359,26 @@
369
359
  text-align: center;
370
360
  gap: 0.75rem;
371
361
  }
372
-
373
362
  .idle-icon {
374
363
  color: var(--text-muted);
375
364
  opacity: 0.4;
376
365
  margin-bottom: 0.5rem;
377
366
  }
378
-
379
367
  .idle-state h2 {
380
368
  font-size: 1.5rem;
381
369
  font-weight: 400;
382
370
  }
383
-
384
371
  .idle-state p {
385
372
  font-size: 0.9375rem;
386
373
  color: var(--text-muted);
387
374
  max-width: 360px;
388
375
  }
389
-
390
376
  .idle-link {
391
377
  font-size: 0.875rem;
392
378
  color: var(--accent);
393
379
  text-decoration: none;
394
380
  margin-top: 0.5rem;
395
381
  }
396
-
397
382
  .idle-link:hover {
398
383
  text-decoration: underline;
399
384
  }
@@ -408,11 +393,9 @@
408
393
  text-align: center;
409
394
  gap: 0.75rem;
410
395
  }
411
-
412
396
  .cron-running-icon {
413
397
  margin-bottom: 0.5rem;
414
398
  }
415
-
416
399
  .cron-pulse-dot {
417
400
  display: inline-block;
418
401
  width: 18px;
@@ -421,7 +404,6 @@
421
404
  background: var(--pass);
422
405
  animation: pulse-pass 1.4s ease-in-out infinite;
423
406
  }
424
-
425
407
  @keyframes pulse-pass {
426
408
  0%,
427
409
  100% {
@@ -433,29 +415,25 @@
433
415
  transform: scale(0.8);
434
416
  }
435
417
  }
436
-
437
418
  .cron-running-state h2 {
438
419
  font-size: 1.5rem;
439
420
  font-weight: 400;
440
421
  }
441
-
442
422
  .cron-task-list {
443
423
  display: flex;
444
424
  flex-wrap: wrap;
445
425
  gap: 0.5rem;
446
426
  justify-content: center;
447
427
  }
448
-
449
428
  .cron-task-chip {
450
429
  font-size: 0.8125rem;
451
430
  font-weight: 500;
452
431
  color: var(--accent);
453
432
  background: var(--accent-soft);
454
433
  border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
455
- border-radius: 100px;
434
+ border-radius: var(--radius-pill);
456
435
  padding: 0.2rem 0.65rem;
457
436
  }
458
-
459
437
  .cron-running-state p {
460
438
  font-size: 0.9375rem;
461
439
  color: var(--text-muted);
@@ -468,15 +446,13 @@
468
446
  align-items: center;
469
447
  justify-content: space-between;
470
448
  gap: 1rem;
471
- padding: 1rem 1.25rem;
472
- margin-bottom: 0;
449
+ padding: 0.875rem 1.25rem;
473
450
  background: var(--bg-elevated);
474
451
  border: 1px solid var(--border);
475
452
  border-bottom: none;
476
453
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
477
454
  animation: fadeUp 0.3s var(--ease-out) both;
478
455
  }
479
-
480
456
  .run-header.header-pass {
481
457
  border-top: 3px solid var(--pass);
482
458
  }
@@ -491,7 +467,6 @@
491
467
  flex-wrap: wrap;
492
468
  }
493
469
 
494
- /* Live badge */
495
470
  .live-badge {
496
471
  display: inline-flex;
497
472
  align-items: center;
@@ -500,12 +475,11 @@
500
475
  font-weight: 600;
501
476
  letter-spacing: 0.08em;
502
477
  text-transform: uppercase;
503
- color: #fff;
478
+ color: var(--white);
504
479
  background: var(--accent);
505
480
  padding: 0.2rem 0.6rem;
506
- border-radius: 100px;
481
+ border-radius: var(--radius-pill);
507
482
  }
508
-
509
483
  .live-dot {
510
484
  width: 6px;
511
485
  height: 6px;
@@ -514,78 +488,39 @@
514
488
  animation: dotPulse 1.2s ease-in-out infinite;
515
489
  }
516
490
 
517
- @keyframes dotPulse {
518
- 0%,
519
- 100% {
520
- opacity: 1;
521
- transform: scale(1);
522
- }
523
- 50% {
524
- opacity: 0.4;
525
- transform: scale(0.65);
526
- }
527
- }
528
-
529
- /* Result badges */
530
- .result-badge {
531
- display: inline-flex;
532
- align-items: center;
533
- gap: 0.4rem;
534
- font-size: 0.7rem;
535
- font-weight: 600;
536
- letter-spacing: 0.08em;
537
- text-transform: uppercase;
538
- padding: 0.2rem 0.6rem;
539
- border-radius: 100px;
540
- }
541
-
542
- .pass-badge {
543
- background: var(--pass-soft);
544
- color: var(--pass);
545
- }
546
- .fail-badge {
547
- background: var(--fail-soft);
548
- color: var(--fail);
549
- }
550
-
551
491
  .run-info {
552
492
  display: flex;
553
493
  align-items: center;
554
494
  gap: 0.35rem;
555
495
  flex-wrap: wrap;
556
496
  }
557
-
558
497
  .run-title-label {
559
498
  font-size: 0.875rem;
560
499
  font-weight: 500;
561
500
  color: var(--accent);
562
501
  background: var(--accent-soft);
563
502
  border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
564
- border-radius: 100px;
503
+ border-radius: var(--radius-pill);
565
504
  padding: 0.15rem 0.6rem;
566
505
  white-space: nowrap;
567
506
  }
568
-
569
507
  .run-tag-label {
570
508
  font-family: 'JetBrains Mono', monospace;
571
509
  font-size: 0.8125rem;
572
510
  font-weight: 500;
573
511
  color: var(--text);
574
512
  }
575
-
576
513
  .run-sep {
577
514
  color: var(--text-muted);
578
515
  opacity: 0.4;
579
516
  font-size: 0.75rem;
580
517
  }
581
-
582
518
  .run-detail {
583
519
  font-size: 0.8rem;
584
520
  color: var(--text-muted);
585
521
  font-family: 'JetBrains Mono', monospace;
586
522
  }
587
523
 
588
- /* Cancel button */
589
524
  .cancel-btn {
590
525
  display: inline-flex;
591
526
  align-items: center;
@@ -604,10 +539,9 @@
604
539
  color var(--duration-fast);
605
540
  flex-shrink: 0;
606
541
  }
607
-
608
542
  .cancel-btn:hover {
609
543
  background: var(--fail);
610
- color: #fff;
544
+ color: var(--white);
611
545
  }
612
546
 
613
547
  /* ── Completion bar ── */
@@ -620,21 +554,17 @@
620
554
  border: 1px solid var(--border);
621
555
  border-top: none;
622
556
  border-bottom: none;
623
- animation: fadeUp 0.3s var(--ease-out) both;
624
557
  }
625
-
626
558
  .completion-bar.pass-bar {
627
559
  background: var(--pass-soft);
628
560
  border-color: color-mix(in srgb, var(--pass) 30%, var(--border));
629
561
  color: var(--pass);
630
562
  }
631
-
632
563
  .completion-bar.fail-bar {
633
564
  background: var(--fail-soft);
634
565
  border-color: color-mix(in srgb, var(--fail) 30%, var(--border));
635
566
  color: var(--fail);
636
567
  }
637
-
638
568
  .completion-left {
639
569
  display: flex;
640
570
  align-items: center;
@@ -642,20 +572,17 @@
642
572
  font-size: 0.9rem;
643
573
  font-weight: 500;
644
574
  }
645
-
646
575
  .completion-right {
647
576
  display: flex;
648
577
  align-items: center;
649
578
  gap: 0.875rem;
650
579
  }
651
-
652
580
  .redirect-hint {
653
581
  font-size: 0.8rem;
654
582
  opacity: 0.7;
655
583
  font-family: 'JetBrains Mono', monospace;
656
584
  color: inherit;
657
585
  }
658
-
659
586
  .view-now-btn {
660
587
  display: inline-flex;
661
588
  align-items: center;
@@ -671,22 +598,152 @@
671
598
  cursor: pointer;
672
599
  transition: background var(--duration-fast);
673
600
  }
674
-
675
601
  .view-now-btn:hover {
676
602
  background: rgba(0, 0, 0, 0.06);
677
603
  }
678
604
 
679
- /* ── Terminal ── */
680
- .terminal-wrap {
605
+ /* ── Lane tabs ── */
606
+ .lane-tabs {
607
+ display: flex;
608
+ gap: 2px;
609
+ padding: 0.5rem 1rem 0;
610
+ background: var(--bg-elevated);
611
+ border: 1px solid var(--border);
612
+ border-top: none;
613
+ border-bottom: none;
614
+ }
615
+ .lane-tab {
616
+ display: inline-flex;
617
+ align-items: center;
618
+ gap: 0.4rem;
619
+ padding: 0.375rem 0.875rem;
620
+ border: 1px solid transparent;
621
+ border-bottom: none;
622
+ border-radius: var(--radius-sm) var(--radius-sm) 0 0;
623
+ background: transparent;
624
+ font-family: var(--font-body);
625
+ font-size: 0.8rem;
626
+ color: var(--text-muted);
627
+ cursor: pointer;
628
+ transition:
629
+ color var(--duration-fast),
630
+ background var(--duration-fast),
631
+ border-color var(--duration-fast);
632
+ white-space: nowrap;
633
+ }
634
+ .lane-tab:hover {
635
+ color: var(--text);
636
+ background: var(--bg-subtle);
637
+ }
638
+ .lane-tab.active {
639
+ color: var(--text);
640
+ background: var(--bg-elevated);
641
+ border-color: var(--border);
642
+ border-bottom-color: var(--bg-elevated);
643
+ }
644
+ .tab-dot {
645
+ width: 6px;
646
+ height: 6px;
647
+ border-radius: 50%;
648
+ flex-shrink: 0;
649
+ background: var(--accent);
650
+ }
651
+ .tab-dot-running {
652
+ background: var(--accent);
653
+ animation: dotPulse 1.6s ease-in-out infinite;
654
+ }
655
+ .tab-dot-done {
656
+ background: var(--pass);
657
+ }
658
+ .tab-dot-error {
659
+ background: var(--fail);
660
+ }
661
+ .tab-count {
662
+ font-size: 0.65rem;
663
+ font-weight: 600;
664
+ font-family: 'JetBrains Mono', monospace;
665
+ opacity: 0.55;
666
+ }
667
+
668
+ /* ── Dual-stream view ── */
669
+ .run-view {
670
+ display: grid;
671
+ grid-template-columns: 1fr 420px;
672
+ height: calc(100vh - 290px);
673
+ min-height: 380px;
681
674
  border: 1px solid var(--border);
682
675
  border-radius: 0 0 var(--radius-lg) var(--radius-lg);
676
+ border-top: none;
683
677
  overflow: hidden;
684
678
  animation: fadeUp 0.35s var(--ease-out) 0.05s both;
685
679
  }
680
+ .run-view.no-top-radius {
681
+ border-radius: 0 0 var(--radius-lg) var(--radius-lg);
682
+ }
686
683
 
687
- .run-header + .terminal-wrap,
688
- .run-header + .completion-bar + .terminal-wrap {
689
- border-top: none;
684
+ /* ── Screenshot panel ── */
685
+ .screenshot-panel {
686
+ position: relative;
687
+ background: var(--terminal-bg);
688
+ display: flex;
689
+ align-items: center;
690
+ justify-content: center;
691
+ overflow: hidden;
692
+ border-right: 1px solid var(--border);
693
+ }
694
+
695
+ .live-shot {
696
+ max-width: 100%;
697
+ max-height: 100%;
698
+ object-fit: contain;
699
+ display: block;
700
+ border-radius: 2px;
701
+ }
702
+
703
+ .step-overlay {
704
+ position: absolute;
705
+ bottom: 0;
706
+ left: 0;
707
+ right: 0;
708
+ padding: 1.25rem 1rem 0.625rem;
709
+ background: linear-gradient(transparent, rgba(0, 0, 0, 0.75));
710
+ display: flex;
711
+ align-items: baseline;
712
+ gap: 0.5rem;
713
+ pointer-events: none;
714
+ }
715
+ .step-keyword {
716
+ font-size: 0.65rem;
717
+ font-weight: 700;
718
+ letter-spacing: 0.1em;
719
+ text-transform: uppercase;
720
+ color: var(--accent);
721
+ flex-shrink: 0;
722
+ }
723
+ .step-name {
724
+ font-size: 0.8125rem;
725
+ color: rgba(255, 255, 255, 0.85);
726
+ font-family: 'JetBrains Mono', monospace;
727
+ white-space: nowrap;
728
+ overflow: hidden;
729
+ text-overflow: ellipsis;
730
+ }
731
+
732
+ .awaiting-state {
733
+ display: flex;
734
+ flex-direction: column;
735
+ align-items: center;
736
+ gap: 0.625rem;
737
+ color: rgba(255, 255, 255, 0.2);
738
+ font-size: 0.8rem;
739
+ font-family: 'JetBrains Mono', monospace;
740
+ }
741
+
742
+ /* ── Terminal panel ── */
743
+ .terminal-panel {
744
+ display: flex;
745
+ flex-direction: column;
746
+ overflow: hidden;
690
747
  }
691
748
 
692
749
  .terminal-bar {
@@ -695,15 +752,14 @@
695
752
  gap: 6px;
696
753
  padding: 0.6rem 0.875rem;
697
754
  background: rgba(0, 0, 0, 0.35);
755
+ flex-shrink: 0;
698
756
  }
699
-
700
757
  .dot {
701
758
  display: block;
702
759
  width: 10px;
703
760
  height: 10px;
704
761
  border-radius: 50%;
705
762
  }
706
-
707
763
  .dot.red {
708
764
  background: #ff5f57;
709
765
  }
@@ -713,7 +769,6 @@
713
769
  .dot.green {
714
770
  background: #28c840;
715
771
  }
716
-
717
772
  .terminal-label {
718
773
  margin-left: auto;
719
774
  font-size: 0.68rem;
@@ -731,14 +786,12 @@
731
786
  background: var(--terminal-bg);
732
787
  color: var(--terminal-text);
733
788
  padding: 1rem 1.25rem;
734
- height: calc(100vh - 320px);
735
- min-height: 240px;
789
+ flex: 1;
736
790
  overflow-y: auto;
737
791
  white-space: pre-wrap;
738
792
  word-break: break-word;
739
793
  margin: 0;
740
794
  }
741
-
742
795
  .terminal::-webkit-scrollbar {
743
796
  width: 4px;
744
797
  }
@@ -749,59 +802,4 @@
749
802
  background: rgba(255, 255, 255, 0.1);
750
803
  border-radius: 2px;
751
804
  }
752
-
753
- /* ── Multi-runner lanes ── */
754
- .lanes-grid {
755
- display: grid;
756
- gap: 1rem;
757
- animation: fadeUp 0.35s var(--ease-out) both;
758
- }
759
-
760
- .lane-terminal {
761
- border: 1px solid var(--border);
762
- border-radius: var(--radius-md);
763
- overflow: hidden;
764
- }
765
-
766
- .lane-header {
767
- display: flex;
768
- align-items: center;
769
- gap: 0.5rem;
770
- padding: 0.625rem 0.875rem;
771
- background: var(--bg-subtle);
772
- border-bottom: 1px solid var(--border);
773
- }
774
-
775
- .lane-dot {
776
- width: 7px;
777
- height: 7px;
778
- border-radius: 50%;
779
- flex-shrink: 0;
780
- }
781
-
782
- .lane-name {
783
- font-size: 0.8125rem;
784
- font-weight: 500;
785
- color: var(--text);
786
- }
787
-
788
- .lane-count {
789
- font-size: 0.72rem;
790
- color: var(--text-muted);
791
- font-family: 'JetBrains Mono', monospace;
792
- }
793
-
794
- .lane-status-text {
795
- margin-left: auto;
796
- font-size: 0.7rem;
797
- font-weight: 600;
798
- letter-spacing: 0.06em;
799
- text-transform: uppercase;
800
- }
801
-
802
- .lane-terminal .terminal {
803
- height: 280px;
804
- min-height: unset;
805
- font-size: 0.75rem;
806
- }
807
805
  </style>