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.
- package/README.md +20 -19
- package/backend/_scaffold/utils/browser.ts +7 -63
- package/backend/_scaffold/utils/hooks.ts +5 -20
- package/backend/app.js +0 -5
- package/backend/config/scripts/generate-report.js +2 -2
- package/backend/config/scripts/run-tests.js +5 -1
- package/backend/constants/socketEvents.js +7 -4
- package/backend/lib/plumTestRuntime.js +262 -0
- package/backend/lib/reportFilename.js +1 -2
- package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
- package/backend/lib/serverBootstrap.js +14 -0
- package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
- package/backend/mcp/server.js +3 -47
- package/backend/package-lock.json +199 -1
- package/backend/package.json +3 -1
- package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
- package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
- package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
- package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
- package/backend/prisma/schema.prisma +97 -70
- package/backend/routes/backup.routes.js +47 -1
- package/backend/routes/reports.routes.js +23 -0
- package/backend/server.js +1 -1
- package/backend/services/backupCronService.js +1 -1
- package/backend/services/backupService.js +134 -44
- package/backend/services/cronService.js +23 -15
- package/backend/services/nodeExecutionService.js +41 -15
- package/backend/services/nodeStreamRegistry.js +24 -0
- package/backend/services/reportService.js +167 -83
- package/backend/services/runnerService.js +19 -7
- package/backend/services/settingsService.js +6 -3
- package/backend/services/triggerService.js +20 -13
- package/backend/websockets/nodeSocketHandler.js +40 -0
- package/backend/websockets/socketHandler.js +9 -7
- package/bin/plum.js +58 -1
- package/frontend/.svelte-kit/ambient.d.ts +28 -28
- package/frontend/.svelte-kit/generated/server/internal.js +1 -1
- package/frontend/package-lock.json +121 -32
- package/frontend/package.json +1 -0
- package/frontend/src/lib/api/reports.js +13 -4
- package/frontend/src/lib/api/settings.js +16 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
- package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
- package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
- package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
- package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
- package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
- package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
- package/frontend/src/lib/constants.js +0 -1
- package/frontend/src/lib/copy/reports.js +18 -8
- package/frontend/src/lib/copy/settings.js +25 -4
- package/frontend/src/lib/socketEvents.js +7 -4
- package/frontend/src/lib/stores/runner.js +26 -3
- package/frontend/src/lib/styles/tokens.css +7 -0
- package/frontend/src/lib/utils/format.js +108 -2
- package/frontend/src/lib/utils/inspectElement.js +34 -0
- package/frontend/src/routes/reports/+page.svelte +79 -1
- package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
- package/frontend/src/routes/reports/live/+page.svelte +236 -260
- package/frontend/src/routes/settings/+page.svelte +246 -8
- package/package.json +1 -1
- 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
|
|
9
|
-
import { slide
|
|
10
|
-
import { fetchReportDetail,
|
|
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
|
-
|
|
17
|
+
groupScenariosByRunnerAndWorker,
|
|
18
18
|
parseRunnerLogs,
|
|
19
|
-
featureSuiteTag
|
|
20
|
-
scenarioHasScreenshots
|
|
19
|
+
featureSuiteTag
|
|
21
20
|
} from '$lib/utils/format';
|
|
22
|
-
import {
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
67
|
-
let
|
|
68
|
-
let replayTimer = null;
|
|
63
|
+
let scenarioRecordings = [];
|
|
64
|
+
let replayInspecting = false;
|
|
69
65
|
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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.
|
|
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.
|
|
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
|
|
393
|
-
|
|
394
|
-
class="
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
<
|
|
413
|
-
<
|
|
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
|
-
{
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
{
|
|
431
|
-
{
|
|
432
|
-
{
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
class
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
<
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
{
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
{/
|
|
579
|
+
{/each}
|
|
551
580
|
</div>
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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"
|
|
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-
|
|
581
|
-
{#if
|
|
582
|
-
<
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
.
|
|
1089
|
-
margin: 0.
|
|
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
|
-
.
|
|
1100
|
-
|
|
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:
|
|
1233
|
+
background: var(--bg-elevated);
|
|
1256
1234
|
display: flex;
|
|
1257
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
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-
|
|
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
|
-
|
|
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>
|