plum-e2e 1.2.4 → 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.
Files changed (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +245 -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 +13 -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 +160 -31
  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 +325 -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 +1 -1
  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';
@@ -63,10 +56,18 @@
63
56
  return scenario.tags.find((tag) => /^@test[\w-]*/i.test(tag));
64
57
  }
65
58
 
59
+ function featureSuiteTag(feature) {
60
+ for (const scenario of feature.scenarios) {
61
+ const suiteTag = scenario.tags.find((tag) => /suite/i.test(tag));
62
+ if (suiteTag) return suiteTag;
63
+ }
64
+ return null;
65
+ }
66
+
66
67
  function visibleTags(scenario) {
67
68
  const testTag = scenarioTestTag(scenario);
68
69
  if (testTag) return [testTag];
69
- return scenario.tags.filter((tag) => tag !== meta?.tags && !tag.includes('suite'));
70
+ return scenario.tags.filter((tag) => !tag.includes('suite'));
70
71
  }
71
72
 
72
73
  function worstStatus(scenarios) {
@@ -80,11 +81,9 @@
80
81
 
81
82
  function groupedScenarios(scenarios) {
82
83
  const groups = new Map();
83
-
84
84
  for (const scenario of scenarios) {
85
85
  const testTag = scenarioTestTag(scenario);
86
86
  const key = testTag || `${scenario.keyword}:${scenario.name}`;
87
-
88
87
  if (!groups.has(key)) {
89
88
  groups.set(key, {
90
89
  key,
@@ -95,16 +94,15 @@
95
94
  status: scenario.status
96
95
  });
97
96
  }
98
-
99
97
  const group = groups.get(key);
100
98
  group.scenarios.push(scenario);
101
99
  group.duration += scenario.duration;
102
100
  group.status = worstStatus(group.scenarios);
103
101
  }
104
-
105
102
  return Array.from(groups.values());
106
103
  }
107
104
 
105
+ $: overallPass = detail?.status === 'PASS';
108
106
  $: passed =
109
107
  detail?.features.flatMap((f) => f.scenarios).filter((s) => s.status === 'passed').length ?? 0;
110
108
  $: failed =
@@ -115,7 +113,6 @@
115
113
  .filter((s) => s.status === 'skipped' || s.status === 'pending').length ?? 0;
116
114
  $: totalDuration =
117
115
  detail?.features.flatMap((f) => f.scenarios).reduce((s, sc) => s + sc.duration, 0) ?? 0;
118
- $: overallPass = meta?.status === 'PASS';
119
116
  $: groupedFeatures =
120
117
  detail?.features.map((feature) => ({
121
118
  ...feature,
@@ -147,77 +144,240 @@
147
144
  <div class="error-state">{error}</div>
148
145
  {:else if !detail}
149
146
  <div class="loading-state">
150
- <div class="loading-dots">
151
- <span></span><span></span><span></span>
152
- </div>
147
+ <div class="loading-dots"><span></span><span></span><span></span></div>
153
148
  </div>
154
149
  {:else}
155
- <!-- Header -->
156
150
  <div class="report-header" class:pass={overallPass} class:fail={!overallPass}>
157
151
  <div class="header-main">
158
152
  <div class="header-status">
159
- <span class="status-icon">{overallPass ? '✓' : '✗'}</span>
153
+ <div class="status-icon-wrap" class:pass-bg={overallPass} class:fail-bg={!overallPass}>
154
+ {#if overallPass}
155
+ <svg
156
+ width="26"
157
+ height="26"
158
+ viewBox="0 0 24 24"
159
+ fill="none"
160
+ stroke="currentColor"
161
+ stroke-width="2.5"
162
+ stroke-linecap="round"
163
+ stroke-linejoin="round"
164
+ >
165
+ <polyline points="20 6 9 17 4 12" />
166
+ </svg>
167
+ {:else}
168
+ <svg
169
+ width="26"
170
+ height="26"
171
+ viewBox="0 0 24 24"
172
+ fill="none"
173
+ stroke="currentColor"
174
+ stroke-width="2.5"
175
+ stroke-linecap="round"
176
+ stroke-linejoin="round"
177
+ >
178
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
179
+ </svg>
180
+ {/if}
181
+ </div>
182
+
160
183
  <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}
184
+ {#if isScheduled(detail.triggerType)}
185
+ <p class="report-task-name">
186
+ <svg
187
+ width="11"
188
+ height="11"
189
+ viewBox="0 0 24 24"
190
+ fill="none"
191
+ stroke="currentColor"
192
+ stroke-width="2"
193
+ stroke-linecap="round"
194
+ stroke-linejoin="round"
195
+ >
196
+ <rect x="3" y="4" width="18" height="18" rx="2" />
197
+ <line x1="16" y1="2" x2="16" y2="6" />
198
+ <line x1="8" y1="2" x2="8" y2="6" />
199
+ <line x1="3" y1="10" x2="21" y2="10" />
200
+ </svg>
201
+ {detail.triggerType}
169
202
  </p>
170
203
  {/if}
204
+ <h1>{overallPass ? 'Passed' : 'Failed'}</h1>
205
+ <div class="header-meta">
206
+ <span class="mono">{detail.tags}</span>
207
+ <span class="meta-sep">·</span>
208
+ <span>{triggerLabel(detail.triggerType)}</span>
209
+ <span class="meta-sep">·</span>
210
+ <span>{new Date(detail.createdAt).toLocaleString()}</span>
211
+ <span class="meta-sep">·</span>
212
+ <span class="browser-pill">
213
+ <BrowserIcon browser={detail.browser ?? 'chromium'} />
214
+ {detail.browser ?? 'chromium'}
215
+ </span>
216
+ {#if detail.runnerName}
217
+ <span class="meta-sep">·</span>
218
+ <span class="runner-pill">
219
+ <svg
220
+ width="10"
221
+ height="10"
222
+ viewBox="0 0 24 24"
223
+ fill="none"
224
+ stroke="currentColor"
225
+ stroke-width="2"
226
+ stroke-linecap="round"
227
+ >
228
+ <rect x="2" y="3" width="20" height="14" rx="2" />
229
+ <path d="M8 21h8M12 17v4" />
230
+ </svg>
231
+ {detail.runnerName}
232
+ </span>
233
+ {/if}
234
+ </div>
171
235
  </div>
172
236
  </div>
173
237
 
174
238
  <div class="header-stats">
175
239
  <div class="stat">
176
240
  <span class="stat-num pass-color">{passed}</span>
177
- <span class="stat-label">passed</span>
241
+ <span class="stat-label">
242
+ <svg
243
+ width="10"
244
+ height="10"
245
+ viewBox="0 0 24 24"
246
+ fill="none"
247
+ stroke="currentColor"
248
+ stroke-width="2.5"
249
+ stroke-linecap="round"
250
+ stroke-linejoin="round"
251
+ >
252
+ <polyline points="20 6 9 17 4 12" />
253
+ </svg>
254
+ passed
255
+ </span>
178
256
  </div>
179
257
  {#if failed > 0}
180
258
  <div class="stat">
181
259
  <span class="stat-num fail-color">{failed}</span>
182
- <span class="stat-label">failed</span>
260
+ <span class="stat-label">
261
+ <svg
262
+ width="10"
263
+ height="10"
264
+ viewBox="0 0 24 24"
265
+ fill="none"
266
+ stroke="currentColor"
267
+ stroke-width="2.5"
268
+ stroke-linecap="round"
269
+ stroke-linejoin="round"
270
+ >
271
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
272
+ </svg>
273
+ failed
274
+ </span>
183
275
  </div>
184
276
  {/if}
185
277
  {#if skipped > 0}
186
278
  <div class="stat">
187
279
  <span class="stat-num muted-color">{skipped}</span>
188
- <span class="stat-label">skipped</span>
280
+ <span class="stat-label">
281
+ <svg
282
+ width="10"
283
+ height="10"
284
+ viewBox="0 0 24 24"
285
+ fill="none"
286
+ stroke="currentColor"
287
+ stroke-width="2.5"
288
+ stroke-linecap="round"
289
+ >
290
+ <line x1="5" y1="12" x2="19" y2="12" />
291
+ </svg>
292
+ skipped
293
+ </span>
189
294
  </div>
190
295
  {/if}
191
296
  <div class="stat">
192
297
  <span class="stat-num">{fmtDuration(totalDuration)}</span>
193
- <span class="stat-label">total</span>
298
+ <span class="stat-label">
299
+ <svg
300
+ width="10"
301
+ height="10"
302
+ viewBox="0 0 24 24"
303
+ fill="none"
304
+ stroke="currentColor"
305
+ stroke-width="2"
306
+ stroke-linecap="round"
307
+ stroke-linejoin="round"
308
+ >
309
+ <circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
310
+ </svg>
311
+ duration
312
+ </span>
313
+ </div>
314
+ <div class="stat">
315
+ <span class="stat-num">{detail.runners}</span>
316
+ <span class="stat-label">
317
+ <svg
318
+ width="10"
319
+ height="10"
320
+ viewBox="0 0 24 24"
321
+ fill="none"
322
+ stroke="currentColor"
323
+ stroke-width="2"
324
+ stroke-linecap="round"
325
+ >
326
+ <rect x="2" y="3" width="20" height="14" rx="2" />
327
+ <path d="M8 21h8M12 17v4" />
328
+ </svg>
329
+ runner{detail.runners !== 1 ? 's' : ''}
330
+ </span>
194
331
  </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
332
  </div>
202
333
  </div>
203
334
  </div>
204
335
 
205
- <!-- Features -->
206
336
  {#each groupedFeatures as feature, fi}
207
- <div class="feature" style="animation-delay: {fi * 60}ms">
337
+ <div
338
+ class="feature"
339
+ class:feature-pass={feature.status === 'passed'}
340
+ class:feature-fail={feature.status !== 'passed'}
341
+ style={stagger(fi, 60)}
342
+ >
208
343
  <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>
344
+ <h2 class="feature-name">
345
+ <svg
346
+ width="13"
347
+ height="13"
348
+ viewBox="0 0 24 24"
349
+ fill="none"
350
+ stroke="currentColor"
351
+ stroke-width="2"
352
+ stroke-linecap="round"
353
+ stroke-linejoin="round"
354
+ class="feature-icon"
355
+ >
356
+ <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" />
357
+ <polyline points="14 2 14 8 20 8" />
358
+ </svg>
359
+ {feature.name}
360
+ {#if featureSuiteTag(feature)}
361
+ <span class="suite-tag">{featureSuiteTag(feature)}</span>
362
+ {/if}
363
+ </h2>
364
+ <div class="feature-right">
365
+ <span class="feature-file">{feature.uri}</span>
366
+ <Badge variant={feature.status === 'passed' ? 'pass' : 'fail'}>
367
+ {feature.status}
368
+ </Badge>
369
+ </div>
214
370
  </div>
215
371
 
216
372
  <div class="scenarios">
217
373
  {#each feature.scenarioGroups as group, si}
218
374
  {@const scenId = `${fi}-${group.key}`}
219
375
  {@const open = expandedScenarios.has(scenId)}
220
- <div class="scenario" style="animation-delay: {(fi * 5 + si) * 40}ms">
376
+ <div
377
+ class="scenario"
378
+ class:scenario-fail={group.status === 'failed'}
379
+ style={stagger(fi * 5 + si, 40)}
380
+ >
221
381
  <button class="scenario-header" on:click={() => toggleScenario(scenId)}>
222
382
  <span
223
383
  class="scenario-status-dot"
@@ -296,7 +456,7 @@
296
456
  <summary class="screenshot-toggle">Screenshot</summary>
297
457
  <img
298
458
  class="screenshot"
299
- src="data:image/png;base64,{step.screenshot}"
459
+ src={screenshotUrl(step.screenshot)}
300
460
  alt="Failure screenshot"
301
461
  />
302
462
  </details>
@@ -332,11 +492,10 @@
332
492
  color: var(--text);
333
493
  }
334
494
 
335
- /* ── Header ── */
336
495
  .report-header {
337
496
  border-radius: var(--radius-lg);
338
497
  border: 1px solid var(--border);
339
- border-left-width: 4px;
498
+ border-top-width: 3px;
340
499
  padding: 1.5rem;
341
500
  margin-bottom: 2rem;
342
501
  background: var(--bg-elevated);
@@ -344,10 +503,10 @@
344
503
  }
345
504
 
346
505
  .report-header.pass {
347
- border-left-color: var(--pass);
506
+ border-top-color: var(--pass);
348
507
  }
349
508
  .report-header.fail {
350
- border-left-color: var(--fail);
509
+ border-top-color: var(--fail);
351
510
  }
352
511
 
353
512
  .header-main {
@@ -364,19 +523,37 @@
364
523
  gap: 1rem;
365
524
  }
366
525
 
367
- .status-icon {
368
- font-size: 2rem;
369
- line-height: 1;
370
- font-weight: 600;
526
+ .status-icon-wrap {
527
+ width: 52px;
528
+ height: 52px;
529
+ border-radius: 50%;
530
+ display: flex;
531
+ align-items: center;
532
+ justify-content: center;
533
+ flex-shrink: 0;
371
534
  }
372
535
 
373
- .report-header.pass .status-icon {
536
+ .status-icon-wrap.pass-bg {
537
+ background: var(--pass-soft);
374
538
  color: var(--pass);
375
539
  }
376
- .report-header.fail .status-icon {
540
+ .status-icon-wrap.fail-bg {
541
+ background: var(--fail-soft);
377
542
  color: var(--fail);
378
543
  }
379
544
 
545
+ .report-task-name {
546
+ display: inline-flex;
547
+ align-items: center;
548
+ gap: 0.35rem;
549
+ font-size: 0.7rem;
550
+ font-weight: 600;
551
+ text-transform: uppercase;
552
+ letter-spacing: 0.1em;
553
+ color: var(--warn);
554
+ margin-bottom: 0.35rem;
555
+ }
556
+
380
557
  h1 {
381
558
  font-size: 2rem;
382
559
  margin-bottom: 0.2rem;
@@ -387,7 +564,12 @@
387
564
  color: var(--text-muted);
388
565
  display: flex;
389
566
  align-items: center;
390
- gap: 0.5rem;
567
+ flex-wrap: wrap;
568
+ gap: 0.35rem;
569
+ }
570
+
571
+ .meta-sep {
572
+ opacity: 0.4;
391
573
  }
392
574
 
393
575
  .mono {
@@ -395,6 +577,29 @@
395
577
  font-size: 0.78rem;
396
578
  }
397
579
 
580
+ .browser-pill {
581
+ display: inline-flex;
582
+ align-items: center;
583
+ gap: 0.3rem;
584
+ font-family: 'JetBrains Mono', monospace;
585
+ font-size: 0.68rem;
586
+ font-weight: 500;
587
+ background: var(--bg-subtle);
588
+ border: 1px solid var(--border);
589
+ border-radius: 100px;
590
+ padding: 0.1rem 0.45rem;
591
+ }
592
+
593
+ .runner-pill {
594
+ display: inline-flex;
595
+ align-items: center;
596
+ gap: 0.3rem;
597
+ font-family: 'JetBrains Mono', monospace;
598
+ font-size: 0.68rem;
599
+ font-weight: 500;
600
+ color: var(--text-muted);
601
+ }
602
+
398
603
  .header-stats {
399
604
  display: flex;
400
605
  gap: 1.5rem;
@@ -406,7 +611,7 @@
406
611
  display: flex;
407
612
  flex-direction: column;
408
613
  align-items: center;
409
- gap: 0.1rem;
614
+ gap: 0.15rem;
410
615
  }
411
616
 
412
617
  .stat-num {
@@ -418,6 +623,9 @@
418
623
  }
419
624
 
420
625
  .stat-label {
626
+ display: inline-flex;
627
+ align-items: center;
628
+ gap: 0.25rem;
421
629
  font-size: 0.68rem;
422
630
  color: var(--text-muted);
423
631
  letter-spacing: 0.06em;
@@ -434,37 +642,77 @@
434
642
  color: var(--text-muted);
435
643
  }
436
644
 
437
- /* ── Features ── */
438
645
  .feature {
439
- margin-bottom: 1.5rem;
646
+ margin-bottom: 1.25rem;
647
+ background: var(--bg-elevated);
648
+ border: 1px solid var(--border);
649
+ border-top-width: 3px;
650
+ border-radius: var(--radius-lg);
651
+ overflow: hidden;
440
652
  animation: fadeUp 0.35s var(--ease-out) both;
441
653
  }
442
654
 
655
+ .feature-pass {
656
+ border-top-color: var(--pass);
657
+ }
658
+ .feature-fail {
659
+ border-top-color: var(--fail);
660
+ }
661
+
443
662
  .feature-header {
444
663
  display: flex;
445
664
  align-items: center;
446
665
  gap: 0.75rem;
447
- margin-bottom: 0.75rem;
666
+ padding: 0.875rem 1.25rem;
667
+ border-bottom: 1px solid var(--border);
668
+ background: var(--bg-subtle);
669
+ }
670
+
671
+ .feature-icon {
672
+ flex-shrink: 0;
673
+ color: var(--text-muted);
674
+ opacity: 0.7;
448
675
  }
449
676
 
450
677
  .feature-name {
451
- font-size: 1.1rem;
678
+ display: flex;
679
+ align-items: center;
680
+ gap: 0.5rem;
681
+ font-size: 0.9375rem;
452
682
  font-family: var(--font-display);
453
683
  font-weight: 400;
684
+ flex: 1;
685
+ min-width: 0;
686
+ }
687
+
688
+ .feature-right {
689
+ display: flex;
690
+ align-items: center;
691
+ gap: 0.75rem;
692
+ flex-shrink: 0;
454
693
  }
455
694
 
456
695
  .feature-file {
457
696
  font-family: 'JetBrains Mono', monospace;
458
697
  font-size: 0.72rem;
459
698
  color: var(--text-muted);
460
- margin-left: auto;
461
699
  }
462
700
 
463
- /* ── Scenarios ── */
701
+ .suite-tag {
702
+ font-size: 0.65rem;
703
+ font-family: 'JetBrains Mono', monospace;
704
+ background: var(--accent-soft);
705
+ color: var(--accent);
706
+ padding: 0.1rem 0.4rem;
707
+ border-radius: 100px;
708
+ font-weight: 500;
709
+ }
710
+
464
711
  .scenarios {
465
712
  display: flex;
466
713
  flex-direction: column;
467
- gap: 0.375rem;
714
+ padding: 0.5rem;
715
+ gap: 0.25rem;
468
716
  }
469
717
 
470
718
  .scenario {
@@ -475,6 +723,11 @@
475
723
  animation: fadeUp 0.3s var(--ease-out) both;
476
724
  }
477
725
 
726
+ .scenario.scenario-fail {
727
+ border-left-width: 3px;
728
+ border-left-color: var(--fail);
729
+ }
730
+
478
731
  .scenario-header {
479
732
  display: flex;
480
733
  align-items: center;
@@ -567,7 +820,6 @@
567
820
  transform: rotate(90deg);
568
821
  }
569
822
 
570
- /* ── Steps ── */
571
823
  .steps {
572
824
  border-top: 1px solid var(--border);
573
825
  background: var(--bg-subtle);
@@ -598,11 +850,9 @@
598
850
  .step {
599
851
  padding: 0.375rem 1rem 0.375rem 1.25rem;
600
852
  }
601
-
602
853
  .step-fail {
603
854
  background: color-mix(in srgb, var(--fail-soft) 40%, transparent);
604
855
  }
605
-
606
856
  .step-skip {
607
857
  opacity: 0.5;
608
858
  }
@@ -702,7 +952,6 @@
702
952
  border: 1px solid var(--border);
703
953
  }
704
954
 
705
- /* ── Loading / error ── */
706
955
  .loading-state {
707
956
  display: flex;
708
957
  justify-content: center;