plum-e2e 2.8.6 → 2.9.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/README.md +20 -19
  2. package/backend/_scaffold/utils/browser.ts +7 -63
  3. package/backend/_scaffold/utils/hooks.ts +5 -20
  4. package/backend/app.js +0 -5
  5. package/backend/config/scripts/generate-report.js +2 -2
  6. package/backend/config/scripts/run-tests.js +5 -1
  7. package/backend/constants/socketEvents.js +7 -4
  8. package/backend/lib/plumTestRuntime.js +262 -0
  9. package/backend/lib/reportFilename.js +1 -2
  10. package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
  11. package/backend/lib/serverBootstrap.js +14 -0
  12. package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
  13. package/backend/mcp/server.js +3 -47
  14. package/backend/package-lock.json +199 -1
  15. package/backend/package.json +3 -1
  16. package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
  17. package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
  18. package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
  19. package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
  20. package/backend/prisma/schema.prisma +97 -70
  21. package/backend/routes/backup.routes.js +47 -1
  22. package/backend/routes/reports.routes.js +23 -0
  23. package/backend/server.js +1 -1
  24. package/backend/services/backupCronService.js +1 -1
  25. package/backend/services/backupService.js +134 -44
  26. package/backend/services/cronService.js +23 -15
  27. package/backend/services/nodeExecutionService.js +41 -15
  28. package/backend/services/nodeStreamRegistry.js +24 -0
  29. package/backend/services/reportService.js +167 -83
  30. package/backend/services/runnerService.js +19 -7
  31. package/backend/services/settingsService.js +6 -3
  32. package/backend/services/triggerService.js +20 -13
  33. package/backend/websockets/nodeSocketHandler.js +40 -0
  34. package/backend/websockets/socketHandler.js +9 -7
  35. package/bin/plum.js +58 -1
  36. package/frontend/.svelte-kit/ambient.d.ts +28 -28
  37. package/frontend/.svelte-kit/generated/server/internal.js +1 -1
  38. package/frontend/package-lock.json +121 -32
  39. package/frontend/package.json +1 -0
  40. package/frontend/src/lib/api/reports.js +13 -4
  41. package/frontend/src/lib/api/settings.js +16 -1
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
  43. package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
  44. package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
  45. package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
  46. package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
  47. package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
  48. package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
  49. package/frontend/src/lib/constants.js +0 -1
  50. package/frontend/src/lib/copy/reports.js +18 -8
  51. package/frontend/src/lib/copy/settings.js +25 -4
  52. package/frontend/src/lib/socketEvents.js +7 -4
  53. package/frontend/src/lib/stores/runner.js +26 -3
  54. package/frontend/src/lib/styles/tokens.css +7 -0
  55. package/frontend/src/lib/utils/format.js +108 -2
  56. package/frontend/src/lib/utils/inspectElement.js +34 -0
  57. package/frontend/src/routes/reports/+page.svelte +79 -1
  58. package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
  59. package/frontend/src/routes/reports/live/+page.svelte +236 -260
  60. package/frontend/src/routes/settings/+page.svelte +246 -8
  61. package/package.json +1 -1
  62. package/backend/playwright.config.js +0 -85
@@ -5,21 +5,20 @@
5
5
 
6
6
  <script>
7
7
  import { page } from '$app/stores';
8
- import { onMount, onDestroy } from 'svelte';
9
- import { slide, fade } from 'svelte/transition';
10
- import { fetchReportDetail, screenshotUrl } from '$lib/api/reports';
8
+ import { onMount } from 'svelte';
9
+ import { slide } from 'svelte/transition';
10
+ import { fetchReportDetail, fetchRecordings } from '$lib/api/reports';
11
11
  import {
12
12
  isScheduled,
13
13
  triggerLabel,
14
14
  fmtDuration,
15
15
  stagger,
16
16
  featureFile,
17
- groupedScenarios,
17
+ groupScenariosByRunnerAndWorker,
18
18
  parseRunnerLogs,
19
- featureSuiteTag,
20
- scenarioHasScreenshots
19
+ featureSuiteTag
21
20
  } from '$lib/utils/format';
22
- import { REPLAY_STEP_MS, BROWSERS } from '$lib/constants';
21
+ import { BROWSERS } from '$lib/constants';
23
22
  import { pluralize } from '$lib/copy/common';
24
23
  import {
25
24
  DETAIL_PAGE_TITLE,
@@ -35,16 +34,12 @@
35
34
  RETRY_TITLE,
36
35
  WATCH_REPLAY_TITLE,
37
36
  REPLAY_LABEL,
38
- SCREENSHOT_TOGGLE_LABEL,
39
- STEP_SCREENSHOT_ALT,
40
- NO_SCREENSHOT_MESSAGE,
41
37
  runnersBadge,
42
38
  casesCountLabel,
43
39
  attemptsLabel,
44
40
  caseLabel,
45
- replayScreenshotAlt,
46
- pauseOrPlayTitle,
47
- replayCounter
41
+ UNKNOWN_RUNNER_LABEL,
42
+ workerLabel
48
43
  } from '$lib/copy/reports';
49
44
  import Badge from '$lib/components/ui/Badge.svelte';
50
45
  import BackLink from '$lib/components/ui/BackLink.svelte';
@@ -53,6 +48,7 @@
53
48
  import StepKeyword from '$lib/components/ui/StepKeyword.svelte';
54
49
  import StepStatusIcon from '$lib/components/ui/StepStatusIcon.svelte';
55
50
  import BrowserIcon from '$lib/components/icons/BrowserIcon.svelte';
51
+ import RecordingPlayer from '$lib/components/reports/RecordingPlayer.svelte';
56
52
 
57
53
  const reportId = parseInt($page.params.id, 10);
58
54
 
@@ -61,84 +57,41 @@
61
57
  let expandedScenarios = new Set();
62
58
 
63
59
  // Replay state
60
+ let allRecordings = [];
64
61
  let replayOpen = false;
65
62
  let replayScenario = null;
66
- let replayIdx = 0;
67
- let replayPlaying = false;
68
- let replayTimer = null;
63
+ let scenarioRecordings = [];
64
+ let replayInspecting = false;
69
65
 
70
- $: replaySteps = replayScenario?.steps ?? [];
71
- $: currentReplayStep = replaySteps[replayIdx] ?? null;
72
-
73
- onDestroy(() => {
74
- if (replayTimer) clearInterval(replayTimer);
75
- });
76
-
77
- function startPlayback() {
78
- replayPlaying = true;
79
- replayTimer = setInterval(() => {
80
- if (replayIdx < (replayScenario?.steps?.length ?? 0) - 1) {
81
- replayIdx++;
82
- } else {
83
- stopPlayback();
84
- }
85
- }, REPLAY_STEP_MS);
86
- }
87
-
88
- function stopPlayback() {
89
- replayPlaying = false;
90
- if (replayTimer) {
91
- clearInterval(replayTimer);
92
- replayTimer = null;
93
- }
66
+ function scenarioHasRecording(scenario) {
67
+ return allRecordings.some((r) => r.scenarioId === scenario.id);
94
68
  }
95
69
 
96
70
  function openReplay(scenario) {
97
- stopPlayback();
98
71
  replayScenario = scenario;
99
- replayIdx = 0;
72
+ scenarioRecordings = allRecordings
73
+ .filter((r) => r.scenarioId === scenario.id)
74
+ .sort((a, b) => a.tabIndex - b.tabIndex);
100
75
  replayOpen = true;
101
- startPlayback();
102
76
  }
103
77
 
104
78
  function closeReplay() {
105
- stopPlayback();
106
79
  replayOpen = false;
107
80
  replayScenario = null;
108
- }
109
-
110
- function togglePlayback() {
111
- if (replayPlaying) {
112
- stopPlayback();
113
- } else {
114
- if (replayIdx >= replaySteps.length - 1) replayIdx = 0;
115
- startPlayback();
116
- }
117
- }
118
-
119
- function replayPrev() {
120
- stopPlayback();
121
- if (replayIdx > 0) replayIdx--;
122
- }
123
-
124
- function replayNext() {
125
- stopPlayback();
126
- if (replayIdx < replaySteps.length - 1) replayIdx++;
81
+ scenarioRecordings = [];
127
82
  }
128
83
 
129
84
  function handleKeydown(e) {
130
85
  if (!replayOpen) return;
131
- if (e.key === 'ArrowRight') replayNext();
132
- else if (e.key === 'ArrowLeft') replayPrev();
133
- else if (e.key === ' ') {
134
- e.preventDefault();
135
- togglePlayback();
136
- } else if (e.key === 'Escape') closeReplay();
86
+ // While inspecting, RecordingPlayer's own Escape handler turns inspect
87
+ // mode off only close the modal when that's not in play.
88
+ if (e.key === 'Escape' && !replayInspecting) closeReplay();
137
89
  }
138
90
 
139
91
  onMount(async () => {
140
92
  try {
141
93
  detail = await fetchReportDetail(reportId);
94
+ allRecordings = await fetchRecordings(reportId);
142
95
  } catch {
143
96
  error = LOAD_ERROR;
144
97
  }
@@ -164,11 +117,11 @@
164
117
  // parallel (multiple workers/runners) — only used as a fallback for reports saved
165
118
  // before this field existed.
166
119
  $: totalDuration = detail?.duration ?? allScenarios.reduce((s, sc) => s + sc.duration, 0);
167
- $: groupedFeatures =
168
- detail?.features.map((feature) => ({
169
- ...feature,
170
- scenarioGroups: groupedScenarios(feature.scenarios)
171
- })) ?? [];
120
+ // `allRecordings` is referenced here (not just inside scenarioHasRecording)
121
+ // purely so Svelte's compiler tracks it as a dependency — recordings load
122
+ // asynchronously after `detail`, and without this the {@const groupHasReplay}
123
+ // checks inside the each-block tree below never re-evaluate once they arrive.
124
+ $: runnerGroups = detail && allRecordings ? groupScenariosByRunnerAndWorker(detail.features) : [];
172
125
  </script>
173
126
 
174
127
  <svelte:window on:keydown={handleKeydown} />
@@ -318,7 +271,7 @@
318
271
  </span>
319
272
  </div>
320
273
  <div class="stat">
321
- <span class="stat-num">{detail.runners}</span>
274
+ <span class="stat-num">{detail.runnerCount}</span>
322
275
  <span class="stat-label">
323
276
  <svg
324
277
  width="10"
@@ -332,9 +285,32 @@
332
285
  <rect x="2" y="3" width="20" height="14" rx="2" />
333
286
  <path d="M8 21h8M12 17v4" />
334
287
  </svg>
335
- {pluralize(detail.runners, 'runner')}
288
+ {pluralize(detail.runnerCount, 'runner')}
336
289
  </span>
337
290
  </div>
291
+ {#if detail.workerCount > 1}
292
+ <div class="stat">
293
+ <span class="stat-num">{detail.workerCount}</span>
294
+ <span class="stat-label">
295
+ <svg
296
+ width="10"
297
+ height="10"
298
+ viewBox="0 0 24 24"
299
+ fill="none"
300
+ stroke="currentColor"
301
+ stroke-width="2"
302
+ stroke-linecap="round"
303
+ stroke-linejoin="round"
304
+ >
305
+ <rect x="3" y="3" width="7" height="7" rx="1" />
306
+ <rect x="14" y="3" width="7" height="7" rx="1" />
307
+ <rect x="3" y="14" width="7" height="7" rx="1" />
308
+ <rect x="14" y="14" width="7" height="7" rx="1" />
309
+ </svg>
310
+ {pluralize(detail.workerCount, 'worker')}
311
+ </span>
312
+ </div>
313
+ {/if}
338
314
  </div>
339
315
  </div>
340
316
  </div>
@@ -389,175 +365,228 @@
389
365
  </details>
390
366
  {/if}
391
367
 
392
- {#each groupedFeatures as feature, fi}
393
- <div
394
- class="feature"
395
- class:feature-pass={feature.status === 'passed'}
396
- class:feature-fail={feature.status !== 'passed'}
397
- style={stagger(fi, 60)}
398
- >
399
- <div class="feature-header">
400
- <h2 class="feature-name">
368
+ {#each runnerGroups as runnerGroup, ri}
369
+ {#if runnerGroups.length > 1}
370
+ <div class="group-header runner-group-header">
371
+ <svg
372
+ class="group-icon"
373
+ width="12"
374
+ height="12"
375
+ viewBox="0 0 24 24"
376
+ fill="none"
377
+ stroke="currentColor"
378
+ stroke-width="2"
379
+ stroke-linecap="round"
380
+ >
381
+ <rect x="2" y="3" width="20" height="14" rx="2" />
382
+ <path d="M8 21h8M12 17v4" />
383
+ </svg>
384
+ {runnerGroup.runnerName ?? UNKNOWN_RUNNER_LABEL}
385
+ </div>
386
+ {/if}
387
+ {#each runnerGroup.workers as workerGroup, wi}
388
+ {#if runnerGroup.workers.length > 1}
389
+ <div class="group-header worker-group-header">
401
390
  <svg
402
- width="13"
403
- height="13"
391
+ class="group-icon"
392
+ width="11"
393
+ height="11"
404
394
  viewBox="0 0 24 24"
405
395
  fill="none"
406
396
  stroke="currentColor"
407
397
  stroke-width="2"
408
398
  stroke-linecap="round"
409
399
  stroke-linejoin="round"
410
- class="feature-icon"
411
400
  >
412
- <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" />
413
- <polyline points="14 2 14 8 20 8" />
401
+ <rect x="3" y="3" width="7" height="7" rx="1" />
402
+ <rect x="14" y="3" width="7" height="7" rx="1" />
403
+ <rect x="3" y="14" width="7" height="7" rx="1" />
404
+ <rect x="14" y="14" width="7" height="7" rx="1" />
414
405
  </svg>
415
- {feature.name}
416
- {#if featureSuiteTag(feature)}
417
- <span class="suite-tag">{featureSuiteTag(feature)}</span>
418
- {/if}
419
- </h2>
420
- <div class="feature-right">
421
- <span class="feature-file" title={feature.uri}>{featureFile(feature.uri)}</span>
422
- <Badge variant={feature.status === 'passed' ? 'pass' : 'fail'}>
423
- {feature.status}
424
- </Badge>
406
+ {workerLabel(workerGroup.workerId)}
425
407
  </div>
426
- </div>
427
-
428
- <div class="scenarios">
429
- {#each feature.scenarioGroups as group, si}
430
- {@const scenId = `${fi}-${group.key}`}
431
- {@const open = expandedScenarios.has(scenId)}
432
- {@const groupHasReplay = group.scenarios.some(scenarioHasScreenshots)}
433
- <div
434
- class="scenario"
435
- class:scenario-fail={group.status === 'failed'}
436
- style={stagger(fi * 5 + si, 40)}
437
- >
438
- <div class="scenario-row">
439
- <button class="scenario-header" on:click={() => toggleScenario(scenId)}>
440
- <StatusDot status={group.status} />
441
-
442
- <span class="scenario-name">
443
- {group.name}
444
- {#if group.scenarios.length > 1}
445
- <span class="scenario-count">{casesCountLabel(group.scenarios.length)}</span>
446
- {/if}
447
- {#if group.scenarios[0]?.attempts > 1}
448
- <span class="scenario-count" title={RETRY_TITLE}>
449
- {attemptsLabel(group.scenarios[0].attempts)}
450
- </span>
451
- {/if}
452
- </span>
453
-
454
- <div class="scenario-tags">
455
- {#each group.tags as tag}
456
- <TagChip {tag} />
457
- {/each}
458
- </div>
459
-
460
- <span class="scenario-duration">{fmtDuration(group.duration)}</span>
461
-
462
- <svg
463
- width="13"
464
- height="13"
465
- viewBox="0 0 24 24"
466
- fill="none"
467
- stroke="currentColor"
468
- stroke-width="2"
469
- stroke-linecap="round"
470
- class="chevron"
471
- class:rotated={open}
472
- >
473
- <polyline points="9 18 15 12 9 6" />
474
- </svg>
475
- </button>
476
-
477
- {#if groupHasReplay && group.scenarios.length === 1}
478
- <button
479
- class="replay-btn"
480
- title={WATCH_REPLAY_TITLE}
481
- on:click={() => openReplay(group.scenarios[0])}
482
- >
483
- <svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" stroke="none">
484
- <polygon points="5,3 19,12 5,21" />
485
- </svg>
486
- {REPLAY_LABEL}
487
- </button>
408
+ {/if}
409
+ {#each workerGroup.features as feature, fi}
410
+ <div
411
+ class="feature"
412
+ class:feature-pass={feature.status === 'passed'}
413
+ class:feature-fail={feature.status !== 'passed'}
414
+ style={stagger(fi, 60)}
415
+ >
416
+ <div class="feature-header">
417
+ <h2 class="feature-name">
418
+ <svg
419
+ width="13"
420
+ height="13"
421
+ viewBox="0 0 24 24"
422
+ fill="none"
423
+ stroke="currentColor"
424
+ stroke-width="2"
425
+ stroke-linecap="round"
426
+ stroke-linejoin="round"
427
+ class="feature-icon"
428
+ >
429
+ <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" />
430
+ <polyline points="14 2 14 8 20 8" />
431
+ </svg>
432
+ {feature.name}
433
+ {#if featureSuiteTag(feature)}
434
+ <span class="suite-tag">{featureSuiteTag(feature)}</span>
488
435
  {/if}
436
+ </h2>
437
+ <div class="feature-right">
438
+ <span class="feature-file" title={feature.uri}>{featureFile(feature.uri)}</span>
439
+ <Badge variant={feature.status === 'passed' ? 'pass' : 'fail'}>
440
+ {feature.status}
441
+ </Badge>
489
442
  </div>
443
+ </div>
490
444
 
491
- {#if open}
492
- <div class="steps" transition:slide={{ duration: 200 }}>
493
- {#each group.scenarios as scenario, exampleIndex}
494
- {#if group.scenarios.length > 1}
495
- <div class="example-header">
496
- <StatusDot status={scenario.status} />
497
- <span>{caseLabel(exampleIndex + 1)}</span>
498
- <span class="scenario-duration">{fmtDuration(scenario.duration)}</span>
499
- {#if scenarioHasScreenshots(scenario)}
500
- <button
501
- class="replay-btn replay-btn-sm"
502
- on:click={() => openReplay(scenario)}
445
+ <div class="scenarios">
446
+ {#each feature.scenarioGroups as group, si}
447
+ {@const scenId = `${ri}-${wi}-${fi}-${group.key}`}
448
+ {@const open = expandedScenarios.has(scenId)}
449
+ {@const groupHasReplay = group.scenarios.some(scenarioHasRecording)}
450
+ <div
451
+ class="scenario"
452
+ class:scenario-fail={group.status === 'failed'}
453
+ style={stagger(fi * 5 + si, 40)}
454
+ >
455
+ <div class="scenario-row">
456
+ <button class="scenario-header" on:click={() => toggleScenario(scenId)}>
457
+ <StatusDot status={group.status} />
458
+
459
+ <span class="scenario-name">
460
+ {group.name}
461
+ {#if group.scenarios.length > 1}
462
+ <span class="scenario-count">{casesCountLabel(group.scenarios.length)}</span
503
463
  >
504
- <svg
505
- width="9"
506
- height="9"
507
- viewBox="0 0 24 24"
508
- fill="currentColor"
509
- stroke="none"
510
- >
511
- <polygon points="5,3 19,12 5,21" />
512
- </svg>
513
- {REPLAY_LABEL}
514
- </button>
515
464
  {/if}
465
+ {#if group.scenarios[0]?.attempts > 1}
466
+ <span class="scenario-count" title={RETRY_TITLE}>
467
+ {attemptsLabel(group.scenarios[0].attempts)}
468
+ </span>
469
+ {/if}
470
+ </span>
471
+
472
+ <div class="scenario-tags">
473
+ {#each group.tags as tag}
474
+ <TagChip {tag} />
475
+ {/each}
516
476
  </div>
517
- {/if}
518
477
 
519
- {#each scenario.steps as step}
520
- <div
521
- class="step"
522
- class:step-fail={step.status === 'failed'}
523
- class:step-skip={step.status === 'skipped' || step.status === 'pending'}
478
+ <span class="scenario-duration">{fmtDuration(group.duration)}</span>
479
+
480
+ <svg
481
+ width="13"
482
+ height="13"
483
+ viewBox="0 0 24 24"
484
+ fill="none"
485
+ stroke="currentColor"
486
+ stroke-width="2"
487
+ stroke-linecap="round"
488
+ class="chevron"
489
+ class:rotated={open}
524
490
  >
525
- <div class="step-row">
526
- <StepStatusIcon status={step.status} />
527
- <StepKeyword keyword={step.keyword} />
528
- <span class="step-name">{step.name}</span>
529
- <span class="step-duration">{fmtDuration(step.duration)}</span>
530
- </div>
531
-
532
- {#if step.error}
533
- <pre class="step-error">{step.error}</pre>
534
- {/if}
491
+ <polyline points="9 18 15 12 9 6" />
492
+ </svg>
493
+ </button>
494
+
495
+ {#if groupHasReplay && group.scenarios.length === 1}
496
+ <button
497
+ class="replay-btn"
498
+ title={WATCH_REPLAY_TITLE}
499
+ on:click={() => openReplay(group.scenarios[0])}
500
+ >
501
+ <svg
502
+ width="11"
503
+ height="11"
504
+ viewBox="0 0 24 24"
505
+ fill="currentColor"
506
+ stroke="none"
507
+ >
508
+ <polygon points="5,3 19,12 5,21" />
509
+ </svg>
510
+ {REPLAY_LABEL}
511
+ </button>
512
+ {/if}
513
+ </div>
535
514
 
536
- {#if step.screenshot}
537
- <details class="screenshot-wrap">
538
- <summary class="screenshot-toggle">{SCREENSHOT_TOGGLE_LABEL}</summary>
539
- <img
540
- class="screenshot"
541
- src={screenshotUrl(step.screenshot)}
542
- alt={STEP_SCREENSHOT_ALT}
543
- />
544
- </details>
515
+ {#if open}
516
+ <div class="steps" transition:slide={{ duration: 200 }}>
517
+ {#each group.scenarios as scenario, exampleIndex}
518
+ {#if group.scenarios.length > 1}
519
+ <div class="example-header">
520
+ <StatusDot status={scenario.status} />
521
+ <span>{caseLabel(exampleIndex + 1)}</span>
522
+ <span class="scenario-duration">{fmtDuration(scenario.duration)}</span>
523
+ {#if scenarioHasRecording(scenario)}
524
+ <button
525
+ class="replay-btn replay-btn-sm"
526
+ on:click={() => openReplay(scenario)}
527
+ >
528
+ <svg
529
+ width="9"
530
+ height="9"
531
+ viewBox="0 0 24 24"
532
+ fill="currentColor"
533
+ stroke="none"
534
+ >
535
+ <polygon points="5,3 19,12 5,21" />
536
+ </svg>
537
+ {REPLAY_LABEL}
538
+ </button>
539
+ {/if}
540
+ </div>
545
541
  {/if}
546
- </div>
547
- {/each}
548
- {/each}
542
+
543
+ {#each scenario.steps as step}
544
+ <div
545
+ class="step"
546
+ class:step-fail={step.status === 'failed'}
547
+ class:step-skip={step.status === 'skipped' || step.status === 'pending'}
548
+ >
549
+ <div class="step-row">
550
+ <StepStatusIcon status={step.status} />
551
+ <StepKeyword keyword={step.keyword} />
552
+ <span class="step-name">{step.name}</span>
553
+ <span class="step-duration">{fmtDuration(step.duration)}</span>
554
+ </div>
555
+
556
+ {#if step.error}
557
+ <pre class="step-error">{step.error}</pre>
558
+ {/if}
559
+
560
+ {#if step.dataTable}
561
+ <table class="step-datatable">
562
+ <tbody>
563
+ {#each step.dataTable as row, ri}
564
+ <tr class:step-datatable-header={ri === 0}>
565
+ {#each row as cell}
566
+ <td>{cell}</td>
567
+ {/each}
568
+ </tr>
569
+ {/each}
570
+ </tbody>
571
+ </table>
572
+ {/if}
573
+ </div>
574
+ {/each}
575
+ {/each}
576
+ </div>
577
+ {/if}
549
578
  </div>
550
- {/if}
579
+ {/each}
551
580
  </div>
552
- {/each}
553
- </div>
554
- </div>
581
+ </div>
582
+ {/each}
583
+ {/each}
555
584
  {/each}
556
585
  {/if}
557
586
 
558
587
  {#if replayOpen && replayScenario}
559
588
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
560
- <div class="replay-overlay" transition:fade={{ duration: 150 }} on:click={closeReplay}>
589
+ <div class="replay-overlay" on:click={closeReplay}>
561
590
  <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
562
591
  <div class="replay-modal" on:click|stopPropagation>
563
592
  <div class="replay-modal-header">
@@ -577,98 +606,16 @@
577
606
  </button>
578
607
  </div>
579
608
 
580
- <div class="replay-screenshot-area">
581
- {#if currentReplayStep?.screenshot}
582
- <img
583
- class="replay-screenshot"
584
- src={screenshotUrl(currentReplayStep.screenshot)}
585
- alt={replayScreenshotAlt(replayIdx + 1)}
609
+ <div class="replay-body">
610
+ {#if scenarioRecordings.length > 0}
611
+ <RecordingPlayer
612
+ {reportId}
613
+ recordings={scenarioRecordings}
614
+ steps={replayScenario.steps}
615
+ bind:inspecting={replayInspecting}
586
616
  />
587
- {:else}
588
- <div class="replay-no-screenshot">{NO_SCREENSHOT_MESSAGE}</div>
589
617
  {/if}
590
618
  </div>
591
-
592
- <div class="replay-timeline">
593
- <input
594
- type="range"
595
- class="replay-slider"
596
- min="0"
597
- max={replaySteps.length - 1}
598
- value={replayIdx}
599
- style="--fill: {replaySteps.length > 1
600
- ? (replayIdx / (replaySteps.length - 1)) * 100
601
- : 0}%"
602
- on:input={(e) => {
603
- stopPlayback();
604
- replayIdx = Number(e.target.value);
605
- }}
606
- />
607
- </div>
608
-
609
- <div class="replay-step-info">
610
- <StepStatusIcon status={currentReplayStep?.status ?? 'skipped'} />
611
- <StepKeyword keyword={currentReplayStep?.keyword ?? ''} />
612
- <span class="replay-step-name">{currentReplayStep?.name}</span>
613
- </div>
614
-
615
- <div class="replay-nav">
616
- <button class="replay-nav-btn" on:click={replayPrev} disabled={replayIdx === 0}>
617
- <svg
618
- width="16"
619
- height="16"
620
- viewBox="0 0 24 24"
621
- fill="none"
622
- stroke="currentColor"
623
- stroke-width="2"
624
- stroke-linecap="round"
625
- >
626
- <polyline points="15 18 9 12 15 6" />
627
- </svg>
628
- </button>
629
-
630
- <button
631
- class="replay-play-btn"
632
- on:click={togglePlayback}
633
- title={pauseOrPlayTitle(replayPlaying)}
634
- >
635
- {#if replayPlaying}
636
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
637
- <rect x="6" y="4" width="4" height="16" rx="1" /><rect
638
- x="14"
639
- y="4"
640
- width="4"
641
- height="16"
642
- rx="1"
643
- />
644
- </svg>
645
- {:else}
646
- <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
647
- <polygon points="5,3 19,12 5,21" />
648
- </svg>
649
- {/if}
650
- </button>
651
-
652
- <button
653
- class="replay-nav-btn"
654
- on:click={replayNext}
655
- disabled={replayIdx === replaySteps.length - 1}
656
- >
657
- <svg
658
- width="16"
659
- height="16"
660
- viewBox="0 0 24 24"
661
- fill="none"
662
- stroke="currentColor"
663
- stroke-width="2"
664
- stroke-linecap="round"
665
- >
666
- <polyline points="9 18 15 12 9 6" />
667
- </svg>
668
- </button>
669
-
670
- <span class="replay-counter">{replayCounter(replayIdx + 1, replaySteps.length)}</span>
671
- </div>
672
619
  </div>
673
620
  </div>
674
621
  {/if}
@@ -826,6 +773,36 @@
826
773
  color: var(--text-muted);
827
774
  }
828
775
 
776
+ .group-header {
777
+ display: flex;
778
+ align-items: center;
779
+ gap: 0.4rem;
780
+ padding-bottom: 0.4rem;
781
+ margin-top: 1.75rem;
782
+ margin-bottom: 0.75rem;
783
+ font-family: 'JetBrains Mono', monospace;
784
+ text-transform: uppercase;
785
+ letter-spacing: 0.05em;
786
+ border-bottom: 1px solid var(--border);
787
+ }
788
+ .group-header:first-child {
789
+ margin-top: 0;
790
+ }
791
+ .group-icon {
792
+ flex-shrink: 0;
793
+ opacity: 0.7;
794
+ }
795
+ .runner-group-header {
796
+ font-size: 0.78rem;
797
+ font-weight: 600;
798
+ color: var(--text);
799
+ }
800
+ .worker-group-header {
801
+ font-size: 0.7rem;
802
+ font-weight: 500;
803
+ color: var(--text-muted);
804
+ }
805
+
829
806
  .feature {
830
807
  margin-bottom: 1.25rem;
831
808
  background: var(--bg-elevated);
@@ -1085,22 +1062,23 @@
1085
1062
  border-left: 2px solid var(--fail);
1086
1063
  }
1087
1064
 
1088
- .screenshot-wrap {
1089
- margin: 0.5rem 0 0.25rem 1.75rem;
1090
- }
1091
-
1092
- .screenshot-toggle {
1065
+ .step-datatable {
1066
+ margin: 0.375rem 0 0.25rem 1.75rem;
1067
+ border-collapse: collapse;
1068
+ font-family: 'JetBrains Mono', monospace;
1093
1069
  font-size: 0.75rem;
1094
- color: var(--text-muted);
1095
- cursor: pointer;
1096
- user-select: none;
1097
1070
  }
1098
1071
 
1099
- .screenshot {
1100
- margin-top: 0.5rem;
1101
- max-width: 100%;
1102
- border-radius: var(--radius-sm);
1072
+ .step-datatable td {
1073
+ padding: 0.35rem 0.7rem;
1103
1074
  border: 1px solid var(--border);
1075
+ color: var(--text);
1076
+ }
1077
+
1078
+ .step-datatable-header td {
1079
+ background: var(--bg-subtle);
1080
+ color: var(--text-muted);
1081
+ font-weight: 600;
1104
1082
  }
1105
1083
 
1106
1084
  .loading-state {
@@ -1252,23 +1230,18 @@
1252
1230
  .replay-overlay {
1253
1231
  position: fixed;
1254
1232
  inset: 0;
1255
- background: rgba(0, 0, 0, 0.72);
1233
+ background: var(--bg-elevated);
1256
1234
  display: flex;
1257
- align-items: center;
1258
- justify-content: center;
1259
- z-index: 200;
1260
- padding: 1rem;
1235
+ z-index: 500;
1261
1236
  }
1262
1237
 
1263
1238
  .replay-modal {
1264
1239
  background: var(--bg-elevated);
1265
- border: 1px solid var(--border);
1266
- border-radius: var(--radius-lg);
1267
- width: min(920px, 100%);
1268
- max-height: 90vh;
1240
+ width: 100%;
1241
+ height: 100%;
1269
1242
  display: flex;
1270
1243
  flex-direction: column;
1271
- overflow: hidden;
1244
+ overflow: hidden auto;
1272
1245
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
1273
1246
  }
1274
1247
 
@@ -1308,175 +1281,11 @@
1308
1281
  color: var(--text);
1309
1282
  }
1310
1283
 
1311
- .replay-screenshot-area {
1284
+ .replay-body {
1312
1285
  flex: 1;
1313
1286
  min-height: 0;
1314
- background: var(--bg-subtle);
1315
- display: flex;
1316
- align-items: center;
1317
- justify-content: center;
1318
1287
  overflow: hidden;
1319
- position: relative;
1320
- }
1321
-
1322
- .replay-screenshot {
1323
- max-width: 100%;
1324
- max-height: 100%;
1325
- object-fit: contain;
1326
- display: block;
1327
- }
1328
-
1329
- .replay-no-screenshot {
1330
- color: var(--text-muted);
1331
- font-size: 0.8125rem;
1332
- font-family: 'JetBrains Mono', monospace;
1333
- }
1334
-
1335
- .replay-step-info {
1336
1288
  display: flex;
1337
- align-items: center;
1338
- gap: 0.5rem;
1339
- padding: 0.75rem 1.25rem;
1340
- border-top: 1px solid var(--border);
1341
- flex-shrink: 0;
1342
- min-width: 0;
1343
- }
1344
-
1345
- .replay-step-name {
1346
- font-family: 'JetBrains Mono', monospace;
1347
- font-size: 0.8125rem;
1348
- color: var(--text);
1349
- overflow: hidden;
1350
- text-overflow: ellipsis;
1351
- white-space: nowrap;
1352
- }
1353
-
1354
- /* ── Timeline slider ── */
1355
- .replay-timeline {
1356
- padding: 0.5rem 1.25rem;
1357
- border-top: 1px solid var(--border);
1358
- flex-shrink: 0;
1359
- }
1360
-
1361
- .replay-slider {
1362
- display: block;
1363
- width: 100%;
1364
- height: 4px;
1365
- -webkit-appearance: none;
1366
- appearance: none;
1367
- background: transparent;
1368
- border-radius: 2px;
1369
- cursor: pointer;
1370
- outline: none;
1371
- }
1372
-
1373
- .replay-slider::-webkit-slider-runnable-track {
1374
- height: 4px;
1375
- border-radius: 2px;
1376
- background: linear-gradient(
1377
- to right,
1378
- var(--accent) var(--fill, 0%),
1379
- var(--border) var(--fill, 0%)
1380
- );
1381
- }
1382
-
1383
- .replay-slider::-webkit-slider-thumb {
1384
- -webkit-appearance: none;
1385
- width: 14px;
1386
- height: 14px;
1387
- margin-top: -5px;
1388
- border-radius: 50%;
1389
- background: var(--accent);
1390
- cursor: pointer;
1391
- transition: transform 0.1s;
1392
- }
1393
-
1394
- .replay-slider:hover::-webkit-slider-thumb {
1395
- transform: scale(1.25);
1396
- }
1397
-
1398
- .replay-slider::-moz-range-track {
1399
- height: 4px;
1400
- border-radius: 2px;
1401
- background: var(--border);
1402
- }
1403
-
1404
- .replay-slider::-moz-range-progress {
1405
- height: 4px;
1406
- border-radius: 2px;
1407
- background: var(--accent);
1408
- }
1409
-
1410
- .replay-slider::-moz-range-thumb {
1411
- width: 14px;
1412
- height: 14px;
1413
- border-radius: 50%;
1414
- background: var(--accent);
1415
- border: none;
1416
- cursor: pointer;
1417
- }
1418
-
1419
- /* ── Nav bar ── */
1420
- .replay-nav {
1421
- display: flex;
1422
- align-items: center;
1423
- justify-content: center;
1424
- gap: 0.75rem;
1425
- padding: 0.5rem 1.25rem 0.625rem;
1426
- border-top: 1px solid var(--border);
1427
- flex-shrink: 0;
1428
- }
1429
-
1430
- .replay-nav-btn {
1431
- display: flex;
1432
- align-items: center;
1433
- justify-content: center;
1434
- width: 30px;
1435
- height: 30px;
1436
- background: var(--bg-subtle);
1437
- border: 1px solid var(--border);
1438
- border-radius: var(--radius-sm);
1439
- color: var(--text);
1440
- cursor: pointer;
1441
- transition: background var(--duration-fast);
1442
- }
1443
-
1444
- .replay-nav-btn:hover:not(:disabled) {
1445
- background: var(--bg-elevated);
1446
- }
1447
-
1448
- .replay-nav-btn:disabled {
1449
- opacity: 0.35;
1450
- cursor: default;
1451
- }
1452
-
1453
- .replay-play-btn {
1454
- display: flex;
1455
- align-items: center;
1456
- justify-content: center;
1457
- width: 38px;
1458
- height: 38px;
1459
- background: var(--accent);
1460
- border: none;
1461
- border-radius: 50%;
1462
- color: var(--white);
1463
- cursor: pointer;
1464
- transition:
1465
- opacity var(--duration-fast),
1466
- transform var(--duration-fast);
1467
- }
1468
-
1469
- .replay-play-btn:hover {
1470
- opacity: 0.85;
1471
- transform: scale(1.05);
1472
- }
1473
-
1474
- .replay-counter {
1475
- font-family: 'JetBrains Mono', monospace;
1476
- font-size: 0.78rem;
1477
- color: var(--text-muted);
1478
- min-width: 5ch;
1479
- text-align: center;
1480
- margin-left: 0.25rem;
1289
+ flex-direction: column;
1481
1290
  }
1482
1291
  </style>