plum-e2e 2.4.13 → 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 (49) 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/frontend/package.json +1 -1
  20. package/frontend/src/lib/components/layout/RunnerPanel.svelte +53 -55
  21. package/frontend/src/lib/components/ui/AutomatedBadge.svelte +56 -0
  22. package/frontend/src/lib/components/ui/BackLink.svelte +77 -0
  23. package/frontend/src/lib/components/ui/Badge.svelte +1 -1
  24. package/frontend/src/lib/components/ui/Button.svelte +1 -1
  25. package/frontend/src/lib/components/ui/CaseIdChip.svelte +59 -0
  26. package/frontend/src/lib/components/ui/ConfirmModal.svelte +1 -1
  27. package/frontend/src/lib/components/ui/Pagination.svelte +1 -1
  28. package/frontend/src/lib/components/ui/PriorityBadge.svelte +83 -0
  29. package/frontend/src/lib/components/ui/ResultChip.svelte +92 -0
  30. package/frontend/src/lib/components/ui/StatusDot.svelte +64 -0
  31. package/frontend/src/lib/components/ui/StepKeyword.svelte +79 -0
  32. package/frontend/src/lib/components/ui/StepStatusIcon.svelte +66 -0
  33. package/frontend/src/lib/components/ui/TagChip.svelte +51 -0
  34. package/frontend/src/lib/constants.js +14 -0
  35. package/frontend/src/lib/stores/runner.js +5 -3
  36. package/frontend/src/lib/styles/tokens.css +26 -0
  37. package/frontend/src/lib/utils/format.js +74 -0
  38. package/frontend/src/routes/+page.svelte +3 -3
  39. package/frontend/src/routes/login/+page.svelte +1 -1
  40. package/frontend/src/routes/reports/+page.svelte +3 -3
  41. package/frontend/src/routes/reports/[id]/+page.svelte +703 -215
  42. package/frontend/src/routes/reports/live/+page.svelte +281 -283
  43. package/frontend/src/routes/scheduled-tests/+page.svelte +3 -3
  44. package/frontend/src/routes/settings/+page.svelte +6 -6
  45. package/frontend/src/routes/setup/+page.svelte +1 -1
  46. package/frontend/src/routes/test-repository/+page.svelte +5 -5
  47. package/frontend/src/routes/test-repository/runs/[id]/+page.svelte +27 -151
  48. package/frontend/src/routes/test-repository/suites/[id]/+page.svelte +4 -4
  49. package/package.json +1 -1
@@ -17,11 +17,27 @@
17
17
 
18
18
  <script>
19
19
  import { page } from '$app/stores';
20
- import { onMount } from 'svelte';
21
- import { slide } from 'svelte/transition';
20
+ import { onMount, onDestroy } from 'svelte';
21
+ import { slide, fade } from 'svelte/transition';
22
22
  import { fetchReportDetail, screenshotUrl } from '$lib/api/reports';
23
- import { isScheduled, triggerLabel, fmtDuration, stagger, featureFile } from '$lib/utils/format';
23
+ import {
24
+ isScheduled,
25
+ triggerLabel,
26
+ fmtDuration,
27
+ stagger,
28
+ featureFile,
29
+ groupedScenarios,
30
+ parseRunnerLogs,
31
+ featureSuiteTag,
32
+ scenarioHasScreenshots
33
+ } from '$lib/utils/format';
34
+ import { REPLAY_STEP_MS } from '$lib/constants';
24
35
  import Badge from '$lib/components/ui/Badge.svelte';
36
+ import BackLink from '$lib/components/ui/BackLink.svelte';
37
+ import StatusDot from '$lib/components/ui/StatusDot.svelte';
38
+ import TagChip from '$lib/components/ui/TagChip.svelte';
39
+ import StepKeyword from '$lib/components/ui/StepKeyword.svelte';
40
+ import StepStatusIcon from '$lib/components/ui/StepStatusIcon.svelte';
25
41
  import BrowserIcon from '$lib/components/icons/BrowserIcon.svelte';
26
42
 
27
43
  const reportId = parseInt($page.params.id, 10);
@@ -30,89 +46,106 @@
30
46
  let error = null;
31
47
  let expandedScenarios = new Set();
32
48
 
33
- onMount(async () => {
34
- try {
35
- detail = await fetchReportDetail(reportId);
36
- } catch {
37
- error = 'Could not load report.';
38
- }
49
+ // Replay state
50
+ let replayOpen = false;
51
+ let replayScenario = null;
52
+ let replayIdx = 0;
53
+ let replayPlaying = false;
54
+ let replayTimer = null;
55
+
56
+ $: replaySteps = replayScenario?.steps ?? [];
57
+ $: currentReplayStep = replaySteps[replayIdx] ?? null;
58
+
59
+ onDestroy(() => {
60
+ if (replayTimer) clearInterval(replayTimer);
39
61
  });
40
62
 
41
- function toggleScenario(id) {
42
- if (expandedScenarios.has(id)) expandedScenarios.delete(id);
43
- else expandedScenarios.add(id);
44
- expandedScenarios = expandedScenarios;
63
+ function startPlayback() {
64
+ replayPlaying = true;
65
+ replayTimer = setInterval(() => {
66
+ if (replayIdx < (replayScenario?.steps?.length ?? 0) - 1) {
67
+ replayIdx++;
68
+ } else {
69
+ stopPlayback();
70
+ }
71
+ }, REPLAY_STEP_MS);
45
72
  }
46
73
 
47
- function keywordClass(kw) {
48
- const k = kw.toLowerCase();
49
- if (k === 'given') return 'kw-given';
50
- if (k === 'when') return 'kw-when';
51
- if (k === 'then') return 'kw-then';
52
- return 'kw-and';
74
+ function stopPlayback() {
75
+ replayPlaying = false;
76
+ if (replayTimer) {
77
+ clearInterval(replayTimer);
78
+ replayTimer = null;
79
+ }
80
+ }
81
+
82
+ function openReplay(scenario) {
83
+ stopPlayback();
84
+ replayScenario = scenario;
85
+ replayIdx = 0;
86
+ replayOpen = true;
87
+ startPlayback();
53
88
  }
54
89
 
55
- function scenarioTestTag(scenario) {
56
- return scenario.tags.find((tag) => /^@test[\w-]*/i.test(tag));
90
+ function closeReplay() {
91
+ stopPlayback();
92
+ replayOpen = false;
93
+ replayScenario = null;
57
94
  }
58
95
 
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;
96
+ function togglePlayback() {
97
+ if (replayPlaying) {
98
+ stopPlayback();
99
+ } else {
100
+ if (replayIdx >= replaySteps.length - 1) replayIdx = 0;
101
+ startPlayback();
63
102
  }
64
- return null;
65
103
  }
66
104
 
67
- function visibleTags(scenario) {
68
- const testTag = scenarioTestTag(scenario);
69
- if (testTag) return [testTag];
70
- return scenario.tags.filter((tag) => !tag.includes('suite'));
105
+ function replayPrev() {
106
+ stopPlayback();
107
+ if (replayIdx > 0) replayIdx--;
71
108
  }
72
109
 
73
- function worstStatus(scenarios) {
74
- const rank = { failed: 3, pending: 2, skipped: 1, passed: 0 };
75
- return scenarios.reduce(
76
- (status, scenario) =>
77
- (rank[scenario.status] ?? 0) > (rank[status] ?? 0) ? scenario.status : status,
78
- 'passed'
79
- );
110
+ function replayNext() {
111
+ stopPlayback();
112
+ if (replayIdx < replaySteps.length - 1) replayIdx++;
80
113
  }
81
114
 
82
- function groupedScenarios(scenarios) {
83
- const groups = new Map();
84
- for (const scenario of scenarios) {
85
- const testTag = scenarioTestTag(scenario);
86
- const key = testTag || `${scenario.keyword}:${scenario.name}`;
87
- if (!groups.has(key)) {
88
- groups.set(key, {
89
- key,
90
- name: scenario.name,
91
- tags: visibleTags(scenario),
92
- scenarios: [],
93
- duration: 0,
94
- status: scenario.status
95
- });
96
- }
97
- const group = groups.get(key);
98
- group.scenarios.push(scenario);
99
- group.duration += scenario.duration;
100
- group.status = worstStatus(group.scenarios);
115
+ function handleKeydown(e) {
116
+ if (!replayOpen) return;
117
+ if (e.key === 'ArrowRight') replayNext();
118
+ else if (e.key === 'ArrowLeft') replayPrev();
119
+ else if (e.key === ' ') {
120
+ e.preventDefault();
121
+ togglePlayback();
122
+ } else if (e.key === 'Escape') closeReplay();
123
+ }
124
+
125
+ onMount(async () => {
126
+ try {
127
+ detail = await fetchReportDetail(reportId);
128
+ } catch {
129
+ error = 'Could not load report.';
101
130
  }
102
- return Array.from(groups.values());
131
+ });
132
+
133
+ function toggleScenario(id) {
134
+ if (expandedScenarios.has(id)) expandedScenarios.delete(id);
135
+ else expandedScenarios.add(id);
136
+ expandedScenarios = expandedScenarios;
103
137
  }
104
138
 
139
+ let activeLogTab = 0;
140
+ $: logSections = parseRunnerLogs(detail?.logs ?? null);
141
+ $: if (logSections) activeLogTab = 0;
142
+
105
143
  $: overallPass = detail?.status === 'PASS';
106
- $: passed =
107
- detail?.features.flatMap((f) => f.scenarios).filter((s) => s.status === 'passed').length ?? 0;
108
- $: failed =
109
- detail?.features.flatMap((f) => f.scenarios).filter((s) => s.status === 'failed').length ?? 0;
110
- $: skipped =
111
- detail?.features
112
- .flatMap((f) => f.scenarios)
113
- .filter((s) => s.status === 'skipped' || s.status === 'pending').length ?? 0;
114
- $: totalDuration =
115
- detail?.features.flatMap((f) => f.scenarios).reduce((s, sc) => s + sc.duration, 0) ?? 0;
144
+ $: allScenarios = detail?.features.flatMap((f) => f.scenarios) ?? [];
145
+ $: passed = allScenarios.filter((s) => s.status === 'passed').length;
146
+ $: failed = allScenarios.filter((s) => s.status === 'failed').length;
147
+ $: skipped = allScenarios.filter((s) => s.status === 'skipped' || s.status === 'pending').length;
148
+ $: totalDuration = allScenarios.reduce((s, sc) => s + sc.duration, 0);
116
149
  $: groupedFeatures =
117
150
  detail?.features.map((feature) => ({
118
151
  ...feature,
@@ -120,25 +153,11 @@
120
153
  })) ?? [];
121
154
  </script>
122
155
 
156
+ <svelte:window on:keydown={handleKeydown} />
157
+
123
158
  <svelte:head><title>Report — Plum</title></svelte:head>
124
159
 
125
- <div class="back-row">
126
- <a href="/reports" class="back-link">
127
- <svg
128
- width="14"
129
- height="14"
130
- viewBox="0 0 24 24"
131
- fill="none"
132
- stroke="currentColor"
133
- stroke-width="2"
134
- stroke-linecap="round"
135
- >
136
- <line x1="19" y1="12" x2="5" y2="12" />
137
- <polyline points="12 19 5 12 12 5" />
138
- </svg>
139
- Reports
140
- </a>
141
- </div>
160
+ <BackLink href="/reports" label="Reports" />
142
161
 
143
162
  {#if error}
144
163
  <div class="error-state">{error}</div>
@@ -302,6 +321,56 @@
302
321
  </div>
303
322
  </div>
304
323
 
324
+ {#if logSections.length > 0}
325
+ <details class="logs-section">
326
+ <summary class="logs-summary">
327
+ <svg
328
+ width="13"
329
+ height="13"
330
+ viewBox="0 0 24 24"
331
+ fill="none"
332
+ stroke="currentColor"
333
+ stroke-width="2"
334
+ stroke-linecap="round"
335
+ stroke-linejoin="round"
336
+ >
337
+ <polyline points="4 17 10 11 4 5" /><line x1="12" y1="19" x2="20" y2="19" />
338
+ </svg>
339
+ Run Logs
340
+ {#if logSections.length > 1}
341
+ <span class="log-runner-count">{logSections.length} runners</span>
342
+ {/if}
343
+ </summary>
344
+
345
+ {#if logSections.length > 1}
346
+ <div class="log-tabs">
347
+ {#each logSections as section, i}
348
+ <button
349
+ class="log-tab"
350
+ class:log-tab-active={activeLogTab === i}
351
+ on:click|stopPropagation={() => (activeLogTab = i)}
352
+ >
353
+ <svg
354
+ width="10"
355
+ height="10"
356
+ viewBox="0 0 24 24"
357
+ fill="none"
358
+ stroke="currentColor"
359
+ stroke-width="2"
360
+ stroke-linecap="round"
361
+ >
362
+ <rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" />
363
+ </svg>
364
+ {section.name}
365
+ </button>
366
+ {/each}
367
+ </div>
368
+ {/if}
369
+
370
+ <pre class="logs-body">{logSections[activeLogTab]?.content ?? ''}</pre>
371
+ </details>
372
+ {/if}
373
+
305
374
  {#each groupedFeatures as feature, fi}
306
375
  <div
307
376
  class="feature"
@@ -342,62 +411,85 @@
342
411
  {#each feature.scenarioGroups as group, si}
343
412
  {@const scenId = `${fi}-${group.key}`}
344
413
  {@const open = expandedScenarios.has(scenId)}
414
+ {@const groupHasReplay = group.scenarios.some(scenarioHasScreenshots)}
345
415
  <div
346
416
  class="scenario"
347
417
  class:scenario-fail={group.status === 'failed'}
348
418
  style={stagger(fi * 5 + si, 40)}
349
419
  >
350
- <button class="scenario-header" on:click={() => toggleScenario(scenId)}>
351
- <span
352
- class="scenario-status-dot"
353
- class:pass={group.status === 'passed'}
354
- class:fail={group.status === 'failed'}
355
- class:skip={group.status === 'skipped' || group.status === 'pending'}
356
- ></span>
357
-
358
- <span class="scenario-name">
359
- {group.name}
360
- {#if group.scenarios.length > 1}
361
- <span class="scenario-count">{group.scenarios.length} cases</span>
362
- {/if}
363
- </span>
364
-
365
- <div class="scenario-tags">
366
- {#each group.tags as tag}
367
- <span class="tag-chip">{tag}</span>
368
- {/each}
369
- </div>
420
+ <div class="scenario-row">
421
+ <button class="scenario-header" on:click={() => toggleScenario(scenId)}>
422
+ <StatusDot status={group.status} />
370
423
 
371
- <span class="scenario-duration">{fmtDuration(group.duration)}</span>
424
+ <span class="scenario-name">
425
+ {group.name}
426
+ {#if group.scenarios.length > 1}
427
+ <span class="scenario-count">{group.scenarios.length} cases</span>
428
+ {/if}
429
+ </span>
372
430
 
373
- <svg
374
- width="13"
375
- height="13"
376
- viewBox="0 0 24 24"
377
- fill="none"
378
- stroke="currentColor"
379
- stroke-width="2"
380
- stroke-linecap="round"
381
- class="chevron"
382
- class:rotated={open}
383
- >
384
- <polyline points="9 18 15 12 9 6" />
385
- </svg>
386
- </button>
431
+ <div class="scenario-tags">
432
+ {#each group.tags as tag}
433
+ <TagChip {tag} />
434
+ {/each}
435
+ </div>
436
+
437
+ <span class="scenario-duration">{fmtDuration(group.duration)}</span>
438
+
439
+ <svg
440
+ width="13"
441
+ height="13"
442
+ viewBox="0 0 24 24"
443
+ fill="none"
444
+ stroke="currentColor"
445
+ stroke-width="2"
446
+ stroke-linecap="round"
447
+ class="chevron"
448
+ class:rotated={open}
449
+ >
450
+ <polyline points="9 18 15 12 9 6" />
451
+ </svg>
452
+ </button>
453
+
454
+ {#if groupHasReplay && group.scenarios.length === 1}
455
+ <button
456
+ class="replay-btn"
457
+ title="Watch replay"
458
+ on:click={() => openReplay(group.scenarios[0])}
459
+ >
460
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none">
461
+ <polygon points="5,3 19,12 5,21" />
462
+ </svg>
463
+ Replay
464
+ </button>
465
+ {/if}
466
+ </div>
387
467
 
388
468
  {#if open}
389
469
  <div class="steps" transition:slide={{ duration: 200 }}>
390
470
  {#each group.scenarios as scenario, exampleIndex}
391
471
  {#if group.scenarios.length > 1}
392
472
  <div class="example-header">
393
- <span
394
- class="scenario-status-dot"
395
- class:pass={scenario.status === 'passed'}
396
- class:fail={scenario.status === 'failed'}
397
- class:skip={scenario.status === 'skipped' || scenario.status === 'pending'}
398
- ></span>
473
+ <StatusDot status={scenario.status} />
399
474
  <span>Case {exampleIndex + 1}</span>
400
475
  <span class="scenario-duration">{fmtDuration(scenario.duration)}</span>
476
+ {#if scenarioHasScreenshots(scenario)}
477
+ <button
478
+ class="replay-btn replay-btn-sm"
479
+ on:click={() => openReplay(scenario)}
480
+ >
481
+ <svg
482
+ width="9"
483
+ height="9"
484
+ viewBox="0 0 24 24"
485
+ fill="currentColor"
486
+ stroke="none"
487
+ >
488
+ <polygon points="5,3 19,12 5,21" />
489
+ </svg>
490
+ Replay
491
+ </button>
492
+ {/if}
401
493
  </div>
402
494
  {/if}
403
495
 
@@ -408,10 +500,8 @@
408
500
  class:step-skip={step.status === 'skipped' || step.status === 'pending'}
409
501
  >
410
502
  <div class="step-row">
411
- <span class="step-status-icon">
412
- {#if step.status === 'passed'}✓{:else if step.status === 'failed'}✗{:else}−{/if}
413
- </span>
414
- <span class="kw {keywordClass(step.keyword)}">{step.keyword}</span>
503
+ <StepStatusIcon status={step.status} />
504
+ <StepKeyword keyword={step.keyword} />
415
505
  <span class="step-name">{step.name}</span>
416
506
  <span class="step-duration">{fmtDuration(step.duration)}</span>
417
507
  </div>
@@ -421,12 +511,12 @@
421
511
  {/if}
422
512
 
423
513
  {#if step.screenshot}
424
- <details class="screenshot-wrap" open>
514
+ <details class="screenshot-wrap">
425
515
  <summary class="screenshot-toggle">Screenshot</summary>
426
516
  <img
427
517
  class="screenshot"
428
518
  src={screenshotUrl(step.screenshot)}
429
- alt="Failure screenshot"
519
+ alt="Step screenshot"
430
520
  />
431
521
  </details>
432
522
  {/if}
@@ -442,25 +532,125 @@
442
532
  {/each}
443
533
  {/if}
444
534
 
445
- <style>
446
- .back-row {
447
- margin-bottom: 1.5rem;
448
- }
535
+ {#if replayOpen && replayScenario}
536
+ <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
537
+ <div class="replay-overlay" transition:fade={{ duration: 150 }} on:click={closeReplay}>
538
+ <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
539
+ <div class="replay-modal" on:click|stopPropagation>
540
+ <div class="replay-modal-header">
541
+ <span class="replay-modal-title">{replayScenario.name}</span>
542
+ <button class="replay-close" on:click={closeReplay}>
543
+ <svg
544
+ width="16"
545
+ height="16"
546
+ viewBox="0 0 24 24"
547
+ fill="none"
548
+ stroke="currentColor"
549
+ stroke-width="2"
550
+ stroke-linecap="round"
551
+ >
552
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
553
+ </svg>
554
+ </button>
555
+ </div>
449
556
 
450
- .back-link {
451
- display: inline-flex;
452
- align-items: center;
453
- gap: 0.35rem;
454
- font-size: 0.8125rem;
455
- color: var(--text-muted);
456
- text-decoration: none;
457
- transition: color var(--duration-fast);
458
- }
557
+ <div class="replay-screenshot-area">
558
+ {#if currentReplayStep?.screenshot}
559
+ <img
560
+ class="replay-screenshot"
561
+ src={screenshotUrl(currentReplayStep.screenshot)}
562
+ alt="Step {replayIdx + 1} screenshot"
563
+ />
564
+ {:else}
565
+ <div class="replay-no-screenshot">No screenshot captured for this step</div>
566
+ {/if}
567
+ </div>
459
568
 
460
- .back-link:hover {
461
- color: var(--text);
462
- }
569
+ <div class="replay-timeline">
570
+ <input
571
+ type="range"
572
+ class="replay-slider"
573
+ min="0"
574
+ max={replaySteps.length - 1}
575
+ value={replayIdx}
576
+ style="--fill: {replaySteps.length > 1
577
+ ? (replayIdx / (replaySteps.length - 1)) * 100
578
+ : 0}%"
579
+ on:input={(e) => {
580
+ stopPlayback();
581
+ replayIdx = Number(e.target.value);
582
+ }}
583
+ />
584
+ </div>
585
+
586
+ <div class="replay-step-info">
587
+ <StepStatusIcon status={currentReplayStep?.status ?? 'skipped'} />
588
+ <StepKeyword keyword={currentReplayStep?.keyword ?? ''} />
589
+ <span class="replay-step-name">{currentReplayStep?.name}</span>
590
+ </div>
591
+
592
+ <div class="replay-nav">
593
+ <button class="replay-nav-btn" on:click={replayPrev} disabled={replayIdx === 0}>
594
+ <svg
595
+ width="16"
596
+ height="16"
597
+ viewBox="0 0 24 24"
598
+ fill="none"
599
+ stroke="currentColor"
600
+ stroke-width="2"
601
+ stroke-linecap="round"
602
+ >
603
+ <polyline points="15 18 9 12 15 6" />
604
+ </svg>
605
+ </button>
606
+
607
+ <button
608
+ class="replay-play-btn"
609
+ on:click={togglePlayback}
610
+ title={replayPlaying ? 'Pause' : 'Play'}
611
+ >
612
+ {#if replayPlaying}
613
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
614
+ <rect x="6" y="4" width="4" height="16" rx="1" /><rect
615
+ x="14"
616
+ y="4"
617
+ width="4"
618
+ height="16"
619
+ rx="1"
620
+ />
621
+ </svg>
622
+ {:else}
623
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
624
+ <polygon points="5,3 19,12 5,21" />
625
+ </svg>
626
+ {/if}
627
+ </button>
628
+
629
+ <button
630
+ class="replay-nav-btn"
631
+ on:click={replayNext}
632
+ disabled={replayIdx === replaySteps.length - 1}
633
+ >
634
+ <svg
635
+ width="16"
636
+ height="16"
637
+ viewBox="0 0 24 24"
638
+ fill="none"
639
+ stroke="currentColor"
640
+ stroke-width="2"
641
+ stroke-linecap="round"
642
+ >
643
+ <polyline points="9 18 15 12 9 6" />
644
+ </svg>
645
+ </button>
463
646
 
647
+ <span class="replay-counter">{replayIdx + 1} / {replaySteps.length}</span>
648
+ </div>
649
+ </div>
650
+ </div>
651
+ {/if}
652
+
653
+ <style>
464
654
  .report-header {
465
655
  border-radius: var(--radius-lg);
466
656
  border: 1px solid var(--border);
@@ -526,7 +716,7 @@
526
716
  color: var(--accent);
527
717
  background: var(--accent-soft);
528
718
  border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
529
- border-radius: 100px;
719
+ border-radius: var(--radius-pill);
530
720
  padding: 0.25rem 0.7rem;
531
721
  white-space: nowrap;
532
722
  max-width: 240px;
@@ -567,7 +757,7 @@
567
757
  font-weight: 500;
568
758
  background: var(--bg-subtle);
569
759
  border: 1px solid var(--border);
570
- border-radius: 100px;
760
+ border-radius: var(--radius-pill);
571
761
  padding: 0.1rem 0.45rem;
572
762
  }
573
763
 
@@ -675,7 +865,7 @@
675
865
  background: var(--accent-soft);
676
866
  color: var(--accent);
677
867
  padding: 0.1rem 0.4rem;
678
- border-radius: 100px;
868
+ border-radius: var(--radius-pill);
679
869
  font-weight: 500;
680
870
  }
681
871
 
@@ -699,12 +889,18 @@
699
889
  border-left-color: var(--fail);
700
890
  }
701
891
 
892
+ .scenario-row {
893
+ display: flex;
894
+ align-items: stretch;
895
+ }
896
+
702
897
  .scenario-header {
703
898
  display: flex;
704
899
  align-items: center;
705
900
  gap: 0.75rem;
706
901
  padding: 0.75rem 1rem;
707
- width: 100%;
902
+ flex: 1;
903
+ min-width: 0;
708
904
  background: transparent;
709
905
  border: none;
710
906
  cursor: pointer;
@@ -717,21 +913,34 @@
717
913
  background: var(--bg-subtle);
718
914
  }
719
915
 
720
- .scenario-status-dot {
721
- width: 8px;
722
- height: 8px;
723
- border-radius: 50%;
916
+ .replay-btn {
917
+ display: inline-flex;
918
+ align-items: center;
919
+ gap: 0.3rem;
920
+ padding: 0 0.875rem;
921
+ font-family: var(--font-body);
922
+ font-size: 0.72rem;
923
+ font-weight: 500;
924
+ color: var(--accent);
925
+ background: transparent;
926
+ border: none;
927
+ border-left: 1px solid var(--border);
928
+ cursor: pointer;
724
929
  flex-shrink: 0;
930
+ transition: background var(--duration-fast);
931
+ white-space: nowrap;
725
932
  }
726
933
 
727
- .scenario-status-dot.pass {
728
- background: var(--pass);
729
- }
730
- .scenario-status-dot.fail {
731
- background: var(--fail);
934
+ .replay-btn:hover {
935
+ background: var(--accent-soft);
732
936
  }
733
- .scenario-status-dot.skip {
734
- background: var(--text-muted);
937
+
938
+ .replay-btn-sm {
939
+ margin-left: auto;
940
+ padding: 0.15rem 0.5rem;
941
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
942
+ border-radius: var(--radius-sm);
943
+ font-size: 0.68rem;
735
944
  }
736
945
 
737
946
  .scenario-name {
@@ -754,7 +963,7 @@
754
963
  color: var(--text-muted);
755
964
  background: var(--bg-subtle);
756
965
  border: 1px solid var(--border);
757
- border-radius: 100px;
966
+ border-radius: var(--radius-pill);
758
967
  padding: 0.05rem 0.4rem;
759
968
  white-space: nowrap;
760
969
  }
@@ -765,15 +974,6 @@
765
974
  flex-shrink: 0;
766
975
  }
767
976
 
768
- .tag-chip {
769
- font-size: 0.65rem;
770
- font-family: 'JetBrains Mono', monospace;
771
- background: var(--accent-soft);
772
- color: var(--accent);
773
- padding: 0.1rem 0.4rem;
774
- border-radius: 100px;
775
- }
776
-
777
977
  .scenario-duration {
778
978
  font-size: 0.75rem;
779
979
  color: var(--text-muted);
@@ -834,49 +1034,6 @@
834
1034
  gap: 0.5rem;
835
1035
  }
836
1036
 
837
- .step-status-icon {
838
- font-size: 0.75rem;
839
- font-weight: 600;
840
- width: 14px;
841
- flex-shrink: 0;
842
- color: var(--text-muted);
843
- }
844
-
845
- .step-fail .step-status-icon {
846
- color: var(--fail);
847
- }
848
- .step:not(.step-fail):not(.step-skip) .step-status-icon {
849
- color: var(--pass);
850
- }
851
-
852
- .kw {
853
- font-size: 0.72rem;
854
- font-weight: 500;
855
- letter-spacing: 0.04em;
856
- flex-shrink: 0;
857
- padding: 0.05rem 0.4rem;
858
- border-radius: 3px;
859
- font-family: 'JetBrains Mono', monospace;
860
- }
861
-
862
- .kw-given {
863
- background: var(--accent-soft);
864
- color: var(--accent);
865
- }
866
- .kw-when {
867
- background: var(--warn-soft);
868
- color: var(--warn);
869
- }
870
- .kw-then {
871
- background: var(--pass-soft);
872
- color: var(--pass);
873
- }
874
- .kw-and {
875
- background: var(--bg-elevated);
876
- color: var(--text-muted);
877
- border: 1px solid var(--border);
878
- }
879
-
880
1037
  .step-name {
881
1038
  flex: 1;
882
1039
  font-size: 0.8125rem;
@@ -968,4 +1125,335 @@
968
1125
  text-align: center;
969
1126
  font-size: 0.9375rem;
970
1127
  }
1128
+
1129
+ /* ── Run Logs ── */
1130
+ .logs-section {
1131
+ margin-bottom: 1.25rem;
1132
+ background: var(--bg-elevated);
1133
+ border: 1px solid var(--border);
1134
+ border-radius: var(--radius-lg);
1135
+ overflow: hidden;
1136
+ animation: fadeUp 0.35s var(--ease-out) both;
1137
+ }
1138
+
1139
+ .logs-summary {
1140
+ display: flex;
1141
+ align-items: center;
1142
+ gap: 0.5rem;
1143
+ padding: 0.875rem 1.25rem;
1144
+ font-size: 0.875rem;
1145
+ font-weight: 500;
1146
+ color: var(--text);
1147
+ cursor: pointer;
1148
+ user-select: none;
1149
+ list-style: none;
1150
+ }
1151
+
1152
+ .logs-summary::-webkit-details-marker {
1153
+ display: none;
1154
+ }
1155
+
1156
+ .logs-summary:hover {
1157
+ background: var(--bg-subtle);
1158
+ }
1159
+
1160
+ .log-runner-count {
1161
+ margin-left: auto;
1162
+ font-size: 0.7rem;
1163
+ font-weight: 500;
1164
+ color: var(--text-muted);
1165
+ background: var(--bg-subtle);
1166
+ border: 1px solid var(--border);
1167
+ border-radius: var(--radius-pill);
1168
+ padding: 0.1rem 0.5rem;
1169
+ }
1170
+
1171
+ .log-tabs {
1172
+ display: flex;
1173
+ gap: 0;
1174
+ border-bottom: 1px solid var(--border);
1175
+ background: var(--bg-subtle);
1176
+ overflow-x: auto;
1177
+ }
1178
+
1179
+ .log-tab {
1180
+ display: inline-flex;
1181
+ align-items: center;
1182
+ gap: 0.4rem;
1183
+ padding: 0.5rem 1rem;
1184
+ font-size: 0.78rem;
1185
+ font-weight: 500;
1186
+ font-family: var(--font-body);
1187
+ color: var(--text-muted);
1188
+ background: transparent;
1189
+ border: none;
1190
+ border-bottom: 2px solid transparent;
1191
+ cursor: pointer;
1192
+ white-space: nowrap;
1193
+ transition: color var(--duration-fast);
1194
+ }
1195
+
1196
+ .log-tab:hover {
1197
+ color: var(--text);
1198
+ }
1199
+
1200
+ .log-tab-active {
1201
+ color: var(--text);
1202
+ border-bottom-color: var(--accent);
1203
+ }
1204
+
1205
+ .logs-body {
1206
+ font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Courier New', monospace;
1207
+ font-size: 0.775rem;
1208
+ line-height: 1.7;
1209
+ background: var(--terminal-bg);
1210
+ color: var(--terminal-text);
1211
+ padding: 1rem 1.25rem;
1212
+ max-height: 480px;
1213
+ overflow-y: auto;
1214
+ white-space: pre-wrap;
1215
+ word-break: break-word;
1216
+ margin: 0;
1217
+ border-top: 1px solid var(--border);
1218
+ }
1219
+
1220
+ .logs-body::-webkit-scrollbar {
1221
+ width: 4px;
1222
+ }
1223
+ .logs-body::-webkit-scrollbar-thumb {
1224
+ background: rgba(255, 255, 255, 0.1);
1225
+ border-radius: 2px;
1226
+ }
1227
+
1228
+ /* ── Replay modal ── */
1229
+ .replay-overlay {
1230
+ position: fixed;
1231
+ inset: 0;
1232
+ background: rgba(0, 0, 0, 0.72);
1233
+ display: flex;
1234
+ align-items: center;
1235
+ justify-content: center;
1236
+ z-index: 200;
1237
+ padding: 1rem;
1238
+ }
1239
+
1240
+ .replay-modal {
1241
+ background: var(--bg-elevated);
1242
+ border: 1px solid var(--border);
1243
+ border-radius: var(--radius-lg);
1244
+ width: min(920px, 100%);
1245
+ max-height: 90vh;
1246
+ display: flex;
1247
+ flex-direction: column;
1248
+ overflow: hidden;
1249
+ box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
1250
+ }
1251
+
1252
+ .replay-modal-header {
1253
+ display: flex;
1254
+ align-items: center;
1255
+ justify-content: space-between;
1256
+ padding: 0.875rem 1.25rem;
1257
+ border-bottom: 1px solid var(--border);
1258
+ gap: 1rem;
1259
+ flex-shrink: 0;
1260
+ }
1261
+
1262
+ .replay-modal-title {
1263
+ font-size: 0.875rem;
1264
+ font-weight: 500;
1265
+ color: var(--text);
1266
+ overflow: hidden;
1267
+ text-overflow: ellipsis;
1268
+ white-space: nowrap;
1269
+ }
1270
+
1271
+ .replay-close {
1272
+ background: transparent;
1273
+ border: none;
1274
+ color: var(--text-muted);
1275
+ cursor: pointer;
1276
+ padding: 0.25rem;
1277
+ display: flex;
1278
+ align-items: center;
1279
+ border-radius: var(--radius-sm);
1280
+ flex-shrink: 0;
1281
+ transition: color var(--duration-fast);
1282
+ }
1283
+
1284
+ .replay-close:hover {
1285
+ color: var(--text);
1286
+ }
1287
+
1288
+ .replay-screenshot-area {
1289
+ flex: 1;
1290
+ min-height: 0;
1291
+ background: var(--bg-subtle);
1292
+ display: flex;
1293
+ align-items: center;
1294
+ justify-content: center;
1295
+ overflow: hidden;
1296
+ position: relative;
1297
+ }
1298
+
1299
+ .replay-screenshot {
1300
+ max-width: 100%;
1301
+ max-height: 100%;
1302
+ object-fit: contain;
1303
+ display: block;
1304
+ }
1305
+
1306
+ .replay-no-screenshot {
1307
+ color: var(--text-muted);
1308
+ font-size: 0.8125rem;
1309
+ font-family: 'JetBrains Mono', monospace;
1310
+ }
1311
+
1312
+ .replay-step-info {
1313
+ display: flex;
1314
+ align-items: center;
1315
+ gap: 0.5rem;
1316
+ padding: 0.75rem 1.25rem;
1317
+ border-top: 1px solid var(--border);
1318
+ flex-shrink: 0;
1319
+ min-width: 0;
1320
+ }
1321
+
1322
+ .replay-step-name {
1323
+ font-family: 'JetBrains Mono', monospace;
1324
+ font-size: 0.8125rem;
1325
+ color: var(--text);
1326
+ overflow: hidden;
1327
+ text-overflow: ellipsis;
1328
+ white-space: nowrap;
1329
+ }
1330
+
1331
+ /* ── Timeline slider ── */
1332
+ .replay-timeline {
1333
+ padding: 0.5rem 1.25rem;
1334
+ border-top: 1px solid var(--border);
1335
+ flex-shrink: 0;
1336
+ }
1337
+
1338
+ .replay-slider {
1339
+ display: block;
1340
+ width: 100%;
1341
+ height: 4px;
1342
+ -webkit-appearance: none;
1343
+ appearance: none;
1344
+ background: transparent;
1345
+ border-radius: 2px;
1346
+ cursor: pointer;
1347
+ outline: none;
1348
+ }
1349
+
1350
+ .replay-slider::-webkit-slider-runnable-track {
1351
+ height: 4px;
1352
+ border-radius: 2px;
1353
+ background: linear-gradient(
1354
+ to right,
1355
+ var(--accent) var(--fill, 0%),
1356
+ var(--border) var(--fill, 0%)
1357
+ );
1358
+ }
1359
+
1360
+ .replay-slider::-webkit-slider-thumb {
1361
+ -webkit-appearance: none;
1362
+ width: 14px;
1363
+ height: 14px;
1364
+ margin-top: -5px;
1365
+ border-radius: 50%;
1366
+ background: var(--accent);
1367
+ cursor: pointer;
1368
+ transition: transform 0.1s;
1369
+ }
1370
+
1371
+ .replay-slider:hover::-webkit-slider-thumb {
1372
+ transform: scale(1.25);
1373
+ }
1374
+
1375
+ .replay-slider::-moz-range-track {
1376
+ height: 4px;
1377
+ border-radius: 2px;
1378
+ background: var(--border);
1379
+ }
1380
+
1381
+ .replay-slider::-moz-range-progress {
1382
+ height: 4px;
1383
+ border-radius: 2px;
1384
+ background: var(--accent);
1385
+ }
1386
+
1387
+ .replay-slider::-moz-range-thumb {
1388
+ width: 14px;
1389
+ height: 14px;
1390
+ border-radius: 50%;
1391
+ background: var(--accent);
1392
+ border: none;
1393
+ cursor: pointer;
1394
+ }
1395
+
1396
+ /* ── Nav bar ── */
1397
+ .replay-nav {
1398
+ display: flex;
1399
+ align-items: center;
1400
+ justify-content: center;
1401
+ gap: 0.75rem;
1402
+ padding: 0.5rem 1.25rem 0.625rem;
1403
+ border-top: 1px solid var(--border);
1404
+ flex-shrink: 0;
1405
+ }
1406
+
1407
+ .replay-nav-btn {
1408
+ display: flex;
1409
+ align-items: center;
1410
+ justify-content: center;
1411
+ width: 30px;
1412
+ height: 30px;
1413
+ background: var(--bg-subtle);
1414
+ border: 1px solid var(--border);
1415
+ border-radius: var(--radius-sm);
1416
+ color: var(--text);
1417
+ cursor: pointer;
1418
+ transition: background var(--duration-fast);
1419
+ }
1420
+
1421
+ .replay-nav-btn:hover:not(:disabled) {
1422
+ background: var(--bg-elevated);
1423
+ }
1424
+
1425
+ .replay-nav-btn:disabled {
1426
+ opacity: 0.35;
1427
+ cursor: default;
1428
+ }
1429
+
1430
+ .replay-play-btn {
1431
+ display: flex;
1432
+ align-items: center;
1433
+ justify-content: center;
1434
+ width: 38px;
1435
+ height: 38px;
1436
+ background: var(--accent);
1437
+ border: none;
1438
+ border-radius: 50%;
1439
+ color: var(--white);
1440
+ cursor: pointer;
1441
+ transition:
1442
+ opacity var(--duration-fast),
1443
+ transform var(--duration-fast);
1444
+ }
1445
+
1446
+ .replay-play-btn:hover {
1447
+ opacity: 0.85;
1448
+ transform: scale(1.05);
1449
+ }
1450
+
1451
+ .replay-counter {
1452
+ font-family: 'JetBrains Mono', monospace;
1453
+ font-size: 0.78rem;
1454
+ color: var(--text-muted);
1455
+ min-width: 5ch;
1456
+ text-align: center;
1457
+ margin-left: 0.25rem;
1458
+ }
971
1459
  </style>