plum-e2e 1.2.3 → 1.3.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 (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +7 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +191 -47
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +2 -2
  62. package/frontend/static/favicon.png +0 -0
@@ -19,11 +19,12 @@
19
19
  import { page } from '$app/stores';
20
20
  import { onMount } from 'svelte';
21
21
  import { slide } from 'svelte/transition';
22
- import { fetchReportDetail, parseReport } from '$lib/api/reports';
22
+ import { fetchReportDetail, screenshotUrl } from '$lib/api/reports';
23
+ import { isScheduled, triggerLabel, fmtDuration, stagger } from '$lib/utils/format';
23
24
  import Badge from '$lib/components/ui/Badge.svelte';
25
+ import BrowserIcon from '$lib/components/icons/BrowserIcon.svelte';
24
26
 
25
- const fileName = decodeURIComponent($page.params.slug);
26
- const meta = parseReport(fileName);
27
+ const reportId = parseInt($page.params.id, 10);
27
28
 
28
29
  let detail = null;
29
30
  let error = null;
@@ -31,26 +32,18 @@
31
32
 
32
33
  onMount(async () => {
33
34
  try {
34
- detail = await fetchReportDetail(fileName);
35
- } catch (e) {
35
+ detail = await fetchReportDetail(reportId);
36
+ } catch {
36
37
  error = 'Could not load report.';
37
38
  }
38
39
  });
39
40
 
40
41
  function toggleScenario(id) {
41
- if (expandedScenarios.has(id)) {
42
- expandedScenarios.delete(id);
43
- } else {
44
- expandedScenarios.add(id);
45
- }
42
+ if (expandedScenarios.has(id)) expandedScenarios.delete(id);
43
+ else expandedScenarios.add(id);
46
44
  expandedScenarios = expandedScenarios;
47
45
  }
48
46
 
49
- function fmtDuration(ms) {
50
- if (ms >= 1000) return (ms / 1000).toFixed(2) + 's';
51
- return ms + 'ms';
52
- }
53
-
54
47
  function keywordClass(kw) {
55
48
  const k = kw.toLowerCase();
56
49
  if (k === 'given') return 'kw-given';
@@ -66,7 +59,7 @@
66
59
  function visibleTags(scenario) {
67
60
  const testTag = scenarioTestTag(scenario);
68
61
  if (testTag) return [testTag];
69
- return scenario.tags.filter((tag) => tag !== meta?.tags && !tag.includes('suite'));
62
+ return scenario.tags.filter((tag) => !tag.includes('suite'));
70
63
  }
71
64
 
72
65
  function worstStatus(scenarios) {
@@ -80,11 +73,9 @@
80
73
 
81
74
  function groupedScenarios(scenarios) {
82
75
  const groups = new Map();
83
-
84
76
  for (const scenario of scenarios) {
85
77
  const testTag = scenarioTestTag(scenario);
86
78
  const key = testTag || `${scenario.keyword}:${scenario.name}`;
87
-
88
79
  if (!groups.has(key)) {
89
80
  groups.set(key, {
90
81
  key,
@@ -95,16 +86,15 @@
95
86
  status: scenario.status
96
87
  });
97
88
  }
98
-
99
89
  const group = groups.get(key);
100
90
  group.scenarios.push(scenario);
101
91
  group.duration += scenario.duration;
102
92
  group.status = worstStatus(group.scenarios);
103
93
  }
104
-
105
94
  return Array.from(groups.values());
106
95
  }
107
96
 
97
+ $: overallPass = detail?.status === 'PASS';
108
98
  $: passed =
109
99
  detail?.features.flatMap((f) => f.scenarios).filter((s) => s.status === 'passed').length ?? 0;
110
100
  $: failed =
@@ -115,7 +105,6 @@
115
105
  .filter((s) => s.status === 'skipped' || s.status === 'pending').length ?? 0;
116
106
  $: totalDuration =
117
107
  detail?.features.flatMap((f) => f.scenarios).reduce((s, sc) => s + sc.duration, 0) ?? 0;
118
- $: overallPass = meta?.status === 'PASS';
119
108
  $: groupedFeatures =
120
109
  detail?.features.map((feature) => ({
121
110
  ...feature,
@@ -147,77 +136,237 @@
147
136
  <div class="error-state">{error}</div>
148
137
  {:else if !detail}
149
138
  <div class="loading-state">
150
- <div class="loading-dots">
151
- <span></span><span></span><span></span>
152
- </div>
139
+ <div class="loading-dots"><span></span><span></span><span></span></div>
153
140
  </div>
154
141
  {:else}
155
- <!-- Header -->
156
142
  <div class="report-header" class:pass={overallPass} class:fail={!overallPass}>
157
143
  <div class="header-main">
158
144
  <div class="header-status">
159
- <span class="status-icon">{overallPass ? '✓' : '✗'}</span>
145
+ <div class="status-icon-wrap" class:pass-bg={overallPass} class:fail-bg={!overallPass}>
146
+ {#if overallPass}
147
+ <svg
148
+ width="26"
149
+ height="26"
150
+ viewBox="0 0 24 24"
151
+ fill="none"
152
+ stroke="currentColor"
153
+ stroke-width="2.5"
154
+ stroke-linecap="round"
155
+ stroke-linejoin="round"
156
+ >
157
+ <polyline points="20 6 9 17 4 12" />
158
+ </svg>
159
+ {:else}
160
+ <svg
161
+ width="26"
162
+ height="26"
163
+ viewBox="0 0 24 24"
164
+ fill="none"
165
+ stroke="currentColor"
166
+ stroke-width="2.5"
167
+ stroke-linecap="round"
168
+ stroke-linejoin="round"
169
+ >
170
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
171
+ </svg>
172
+ {/if}
173
+ </div>
174
+
160
175
  <div>
161
- <h1>{overallPass ? 'Passed' : 'Failed'}</h1>
162
- {#if meta}
163
- <p class="header-meta">
164
- <span class="mono">{meta.tags}</span>
165
- ·
166
- {#if meta.triggerType === 'manual-trigger'}Manual{:else if meta.triggerType === 'undefined'}CLI{:else}Scheduled{/if}
167
- ·
168
- {meta.date}
176
+ {#if isScheduled(detail.triggerType)}
177
+ <p class="report-task-name">
178
+ <svg
179
+ width="11"
180
+ height="11"
181
+ viewBox="0 0 24 24"
182
+ fill="none"
183
+ stroke="currentColor"
184
+ stroke-width="2"
185
+ stroke-linecap="round"
186
+ stroke-linejoin="round"
187
+ >
188
+ <rect x="3" y="4" width="18" height="18" rx="2" />
189
+ <line x1="16" y1="2" x2="16" y2="6" />
190
+ <line x1="8" y1="2" x2="8" y2="6" />
191
+ <line x1="3" y1="10" x2="21" y2="10" />
192
+ </svg>
193
+ {detail.triggerType}
169
194
  </p>
170
195
  {/if}
196
+ <h1>{overallPass ? 'Passed' : 'Failed'}</h1>
197
+ <div class="header-meta">
198
+ <span class="mono">{detail.tags}</span>
199
+ <span class="meta-sep">·</span>
200
+ <span>{triggerLabel(detail.triggerType)}</span>
201
+ <span class="meta-sep">·</span>
202
+ <span>{new Date(detail.createdAt).toLocaleString()}</span>
203
+ <span class="meta-sep">·</span>
204
+ <span class="browser-pill">
205
+ <BrowserIcon browser={detail.browser ?? 'chromium'} />
206
+ {detail.browser ?? 'chromium'}
207
+ </span>
208
+ {#if detail.runnerName}
209
+ <span class="meta-sep">·</span>
210
+ <span class="runner-pill">
211
+ <svg
212
+ width="10"
213
+ height="10"
214
+ viewBox="0 0 24 24"
215
+ fill="none"
216
+ stroke="currentColor"
217
+ stroke-width="2"
218
+ stroke-linecap="round"
219
+ >
220
+ <rect x="2" y="3" width="20" height="14" rx="2" />
221
+ <path d="M8 21h8M12 17v4" />
222
+ </svg>
223
+ {detail.runnerName}
224
+ </span>
225
+ {/if}
226
+ </div>
171
227
  </div>
172
228
  </div>
173
229
 
174
230
  <div class="header-stats">
175
231
  <div class="stat">
176
232
  <span class="stat-num pass-color">{passed}</span>
177
- <span class="stat-label">passed</span>
233
+ <span class="stat-label">
234
+ <svg
235
+ width="10"
236
+ height="10"
237
+ viewBox="0 0 24 24"
238
+ fill="none"
239
+ stroke="currentColor"
240
+ stroke-width="2.5"
241
+ stroke-linecap="round"
242
+ stroke-linejoin="round"
243
+ >
244
+ <polyline points="20 6 9 17 4 12" />
245
+ </svg>
246
+ passed
247
+ </span>
178
248
  </div>
179
249
  {#if failed > 0}
180
250
  <div class="stat">
181
251
  <span class="stat-num fail-color">{failed}</span>
182
- <span class="stat-label">failed</span>
252
+ <span class="stat-label">
253
+ <svg
254
+ width="10"
255
+ height="10"
256
+ viewBox="0 0 24 24"
257
+ fill="none"
258
+ stroke="currentColor"
259
+ stroke-width="2.5"
260
+ stroke-linecap="round"
261
+ stroke-linejoin="round"
262
+ >
263
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
264
+ </svg>
265
+ failed
266
+ </span>
183
267
  </div>
184
268
  {/if}
185
269
  {#if skipped > 0}
186
270
  <div class="stat">
187
271
  <span class="stat-num muted-color">{skipped}</span>
188
- <span class="stat-label">skipped</span>
272
+ <span class="stat-label">
273
+ <svg
274
+ width="10"
275
+ height="10"
276
+ viewBox="0 0 24 24"
277
+ fill="none"
278
+ stroke="currentColor"
279
+ stroke-width="2.5"
280
+ stroke-linecap="round"
281
+ >
282
+ <line x1="5" y1="12" x2="19" y2="12" />
283
+ </svg>
284
+ skipped
285
+ </span>
189
286
  </div>
190
287
  {/if}
191
288
  <div class="stat">
192
289
  <span class="stat-num">{fmtDuration(totalDuration)}</span>
193
- <span class="stat-label">total</span>
290
+ <span class="stat-label">
291
+ <svg
292
+ width="10"
293
+ height="10"
294
+ viewBox="0 0 24 24"
295
+ fill="none"
296
+ stroke="currentColor"
297
+ stroke-width="2"
298
+ stroke-linecap="round"
299
+ stroke-linejoin="round"
300
+ >
301
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
302
+ </svg>
303
+ duration
304
+ </span>
305
+ </div>
306
+ <div class="stat">
307
+ <span class="stat-num">{detail.runners}</span>
308
+ <span class="stat-label">
309
+ <svg
310
+ width="10"
311
+ height="10"
312
+ viewBox="0 0 24 24"
313
+ fill="none"
314
+ stroke="currentColor"
315
+ stroke-width="2"
316
+ stroke-linecap="round"
317
+ >
318
+ <rect x="2" y="3" width="20" height="14" rx="2" />
319
+ <path d="M8 21h8M12 17v4" />
320
+ </svg>
321
+ runner{detail.runners !== 1 ? 's' : ''}
322
+ </span>
194
323
  </div>
195
- {#if meta}
196
- <div class="stat">
197
- <span class="stat-num">{meta.runners}</span>
198
- <span class="stat-label">runner{meta.runners !== 1 ? 's' : ''}</span>
199
- </div>
200
- {/if}
201
324
  </div>
202
325
  </div>
203
326
  </div>
204
327
 
205
- <!-- Features -->
206
328
  {#each groupedFeatures as feature, fi}
207
- <div class="feature" style="animation-delay: {fi * 60}ms">
329
+ <div
330
+ class="feature"
331
+ class:feature-pass={feature.status === 'passed'}
332
+ class:feature-fail={feature.status !== 'passed'}
333
+ style={stagger(fi, 60)}
334
+ >
208
335
  <div class="feature-header">
209
- <Badge variant={feature.status === 'passed' ? 'pass' : 'fail'}>
210
- {feature.status}
211
- </Badge>
212
- <h2 class="feature-name">{feature.name}</h2>
213
- <span class="feature-file">{feature.uri}</span>
336
+ <h2 class="feature-name">
337
+ <svg
338
+ width="13"
339
+ height="13"
340
+ viewBox="0 0 24 24"
341
+ fill="none"
342
+ stroke="currentColor"
343
+ stroke-width="2"
344
+ stroke-linecap="round"
345
+ stroke-linejoin="round"
346
+ class="feature-icon"
347
+ >
348
+ <path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
349
+ <polyline points="14 2 14 8 20 8" />
350
+ </svg>
351
+ {feature.name}
352
+ </h2>
353
+ <div class="feature-right">
354
+ <span class="feature-file">{feature.uri}</span>
355
+ <Badge variant={feature.status === 'passed' ? 'pass' : 'fail'}>
356
+ {feature.status}
357
+ </Badge>
358
+ </div>
214
359
  </div>
215
360
 
216
361
  <div class="scenarios">
217
362
  {#each feature.scenarioGroups as group, si}
218
363
  {@const scenId = `${fi}-${group.key}`}
219
364
  {@const open = expandedScenarios.has(scenId)}
220
- <div class="scenario" style="animation-delay: {(fi * 5 + si) * 40}ms">
365
+ <div
366
+ class="scenario"
367
+ class:scenario-fail={group.status === 'failed'}
368
+ style={stagger(fi * 5 + si, 40)}
369
+ >
221
370
  <button class="scenario-header" on:click={() => toggleScenario(scenId)}>
222
371
  <span
223
372
  class="scenario-status-dot"
@@ -296,7 +445,7 @@
296
445
  <summary class="screenshot-toggle">Screenshot</summary>
297
446
  <img
298
447
  class="screenshot"
299
- src="data:image/png;base64,{step.screenshot}"
448
+ src={screenshotUrl(step.screenshot)}
300
449
  alt="Failure screenshot"
301
450
  />
302
451
  </details>
@@ -332,11 +481,10 @@
332
481
  color: var(--text);
333
482
  }
334
483
 
335
- /* ── Header ── */
336
484
  .report-header {
337
485
  border-radius: var(--radius-lg);
338
486
  border: 1px solid var(--border);
339
- border-left-width: 4px;
487
+ border-top-width: 3px;
340
488
  padding: 1.5rem;
341
489
  margin-bottom: 2rem;
342
490
  background: var(--bg-elevated);
@@ -344,10 +492,10 @@
344
492
  }
345
493
 
346
494
  .report-header.pass {
347
- border-left-color: var(--pass);
495
+ border-top-color: var(--pass);
348
496
  }
349
497
  .report-header.fail {
350
- border-left-color: var(--fail);
498
+ border-top-color: var(--fail);
351
499
  }
352
500
 
353
501
  .header-main {
@@ -364,19 +512,37 @@
364
512
  gap: 1rem;
365
513
  }
366
514
 
367
- .status-icon {
368
- font-size: 2rem;
369
- line-height: 1;
370
- font-weight: 600;
515
+ .status-icon-wrap {
516
+ width: 52px;
517
+ height: 52px;
518
+ border-radius: 50%;
519
+ display: flex;
520
+ align-items: center;
521
+ justify-content: center;
522
+ flex-shrink: 0;
371
523
  }
372
524
 
373
- .report-header.pass .status-icon {
525
+ .status-icon-wrap.pass-bg {
526
+ background: var(--pass-soft);
374
527
  color: var(--pass);
375
528
  }
376
- .report-header.fail .status-icon {
529
+ .status-icon-wrap.fail-bg {
530
+ background: var(--fail-soft);
377
531
  color: var(--fail);
378
532
  }
379
533
 
534
+ .report-task-name {
535
+ display: inline-flex;
536
+ align-items: center;
537
+ gap: 0.35rem;
538
+ font-size: 0.7rem;
539
+ font-weight: 600;
540
+ text-transform: uppercase;
541
+ letter-spacing: 0.1em;
542
+ color: var(--warn);
543
+ margin-bottom: 0.35rem;
544
+ }
545
+
380
546
  h1 {
381
547
  font-size: 2rem;
382
548
  margin-bottom: 0.2rem;
@@ -387,7 +553,12 @@
387
553
  color: var(--text-muted);
388
554
  display: flex;
389
555
  align-items: center;
390
- gap: 0.5rem;
556
+ flex-wrap: wrap;
557
+ gap: 0.35rem;
558
+ }
559
+
560
+ .meta-sep {
561
+ opacity: 0.4;
391
562
  }
392
563
 
393
564
  .mono {
@@ -395,6 +566,29 @@
395
566
  font-size: 0.78rem;
396
567
  }
397
568
 
569
+ .browser-pill {
570
+ display: inline-flex;
571
+ align-items: center;
572
+ gap: 0.3rem;
573
+ font-family: 'JetBrains Mono', monospace;
574
+ font-size: 0.68rem;
575
+ font-weight: 500;
576
+ background: var(--bg-subtle);
577
+ border: 1px solid var(--border);
578
+ border-radius: 100px;
579
+ padding: 0.1rem 0.45rem;
580
+ }
581
+
582
+ .runner-pill {
583
+ display: inline-flex;
584
+ align-items: center;
585
+ gap: 0.3rem;
586
+ font-family: 'JetBrains Mono', monospace;
587
+ font-size: 0.68rem;
588
+ font-weight: 500;
589
+ color: var(--text-muted);
590
+ }
591
+
398
592
  .header-stats {
399
593
  display: flex;
400
594
  gap: 1.5rem;
@@ -406,7 +600,7 @@
406
600
  display: flex;
407
601
  flex-direction: column;
408
602
  align-items: center;
409
- gap: 0.1rem;
603
+ gap: 0.15rem;
410
604
  }
411
605
 
412
606
  .stat-num {
@@ -418,6 +612,9 @@
418
612
  }
419
613
 
420
614
  .stat-label {
615
+ display: inline-flex;
616
+ align-items: center;
617
+ gap: 0.25rem;
421
618
  font-size: 0.68rem;
422
619
  color: var(--text-muted);
423
620
  letter-spacing: 0.06em;
@@ -434,37 +631,67 @@
434
631
  color: var(--text-muted);
435
632
  }
436
633
 
437
- /* ── Features ── */
438
634
  .feature {
439
- margin-bottom: 1.5rem;
635
+ margin-bottom: 1.25rem;
636
+ background: var(--bg-elevated);
637
+ border: 1px solid var(--border);
638
+ border-top-width: 3px;
639
+ border-radius: var(--radius-lg);
640
+ overflow: hidden;
440
641
  animation: fadeUp 0.35s var(--ease-out) both;
441
642
  }
442
643
 
644
+ .feature-pass {
645
+ border-top-color: var(--pass);
646
+ }
647
+ .feature-fail {
648
+ border-top-color: var(--fail);
649
+ }
650
+
443
651
  .feature-header {
444
652
  display: flex;
445
653
  align-items: center;
446
654
  gap: 0.75rem;
447
- margin-bottom: 0.75rem;
655
+ padding: 0.875rem 1.25rem;
656
+ border-bottom: 1px solid var(--border);
657
+ background: var(--bg-subtle);
658
+ }
659
+
660
+ .feature-icon {
661
+ flex-shrink: 0;
662
+ color: var(--text-muted);
663
+ opacity: 0.7;
448
664
  }
449
665
 
450
666
  .feature-name {
451
- font-size: 1.1rem;
667
+ display: flex;
668
+ align-items: center;
669
+ gap: 0.5rem;
670
+ font-size: 0.9375rem;
452
671
  font-family: var(--font-display);
453
672
  font-weight: 400;
673
+ flex: 1;
674
+ min-width: 0;
675
+ }
676
+
677
+ .feature-right {
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 0.75rem;
681
+ flex-shrink: 0;
454
682
  }
455
683
 
456
684
  .feature-file {
457
685
  font-family: 'JetBrains Mono', monospace;
458
686
  font-size: 0.72rem;
459
687
  color: var(--text-muted);
460
- margin-left: auto;
461
688
  }
462
689
 
463
- /* ── Scenarios ── */
464
690
  .scenarios {
465
691
  display: flex;
466
692
  flex-direction: column;
467
- gap: 0.375rem;
693
+ padding: 0.5rem;
694
+ gap: 0.25rem;
468
695
  }
469
696
 
470
697
  .scenario {
@@ -475,6 +702,11 @@
475
702
  animation: fadeUp 0.3s var(--ease-out) both;
476
703
  }
477
704
 
705
+ .scenario.scenario-fail {
706
+ border-left-width: 3px;
707
+ border-left-color: var(--fail);
708
+ }
709
+
478
710
  .scenario-header {
479
711
  display: flex;
480
712
  align-items: center;
@@ -567,7 +799,6 @@
567
799
  transform: rotate(90deg);
568
800
  }
569
801
 
570
- /* ── Steps ── */
571
802
  .steps {
572
803
  border-top: 1px solid var(--border);
573
804
  background: var(--bg-subtle);
@@ -598,11 +829,9 @@
598
829
  .step {
599
830
  padding: 0.375rem 1rem 0.375rem 1.25rem;
600
831
  }
601
-
602
832
  .step-fail {
603
833
  background: color-mix(in srgb, var(--fail-soft) 40%, transparent);
604
834
  }
605
-
606
835
  .step-skip {
607
836
  opacity: 0.5;
608
837
  }
@@ -702,7 +931,6 @@
702
931
  border: 1px solid var(--border);
703
932
  }
704
933
 
705
- /* ── Loading / error ── */
706
934
  .loading-state {
707
935
  display: flex;
708
936
  justify-content: center;