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
@@ -0,0 +1,109 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ -->
5
+
6
+ <script>
7
+ import { createEventDispatcher } from 'svelte';
8
+ import StepKeyword from '$lib/components/ui/StepKeyword.svelte';
9
+ import StepStatusIcon from '$lib/components/ui/StepStatusIcon.svelte';
10
+ import { STEPS_RAIL_HEADING } from '$lib/copy/reports';
11
+
12
+ export let steps = [];
13
+ export let stepTimestamps = [];
14
+ export let currentStepIndex = -1;
15
+
16
+ const dispatch = createEventDispatcher();
17
+ </script>
18
+
19
+ <aside class="steps-rail">
20
+ <div class="steps-rail-header">{STEPS_RAIL_HEADING}</div>
21
+ <ol class="steps-list">
22
+ {#each steps as step, i}
23
+ <li>
24
+ <button
25
+ class="rail-step"
26
+ class:rail-step-active={i === currentStepIndex}
27
+ disabled={stepTimestamps[i] === undefined}
28
+ on:click={() => dispatch('jump', i)}
29
+ >
30
+ <StepStatusIcon status={step.status} />
31
+ <span class="rail-step-text">
32
+ <StepKeyword keyword={step.keyword} />
33
+ <span>{step.name}</span>
34
+ </span>
35
+ </button>
36
+ </li>
37
+ {/each}
38
+ </ol>
39
+ </aside>
40
+
41
+ <style>
42
+ .steps-rail {
43
+ flex-shrink: 0;
44
+ width: 240px;
45
+ display: flex;
46
+ flex-direction: column;
47
+ background: var(--bg-elevated);
48
+ overflow-y: auto;
49
+ }
50
+
51
+ .steps-rail-header {
52
+ flex-shrink: 0;
53
+ padding: 0.7rem 0.9rem 0.5rem;
54
+ font-family: 'JetBrains Mono', monospace;
55
+ font-size: 0.68rem;
56
+ font-weight: 600;
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.06em;
59
+ color: var(--text-muted);
60
+ }
61
+
62
+ .steps-list {
63
+ list-style: none;
64
+ margin: 0;
65
+ padding: 0 0.5rem 0.5rem;
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 0.1rem;
69
+ }
70
+
71
+ .rail-step {
72
+ width: 100%;
73
+ display: flex;
74
+ align-items: flex-start;
75
+ gap: 0.5rem;
76
+ padding: 0.4rem 0.5rem;
77
+ background: none;
78
+ border: none;
79
+ border-radius: var(--radius-sm);
80
+ font: inherit;
81
+ font-size: 0.78rem;
82
+ line-height: 1.35;
83
+ text-align: left;
84
+ color: var(--text-muted);
85
+ cursor: pointer;
86
+ transition: background var(--duration-fast) var(--ease-out);
87
+ }
88
+ .rail-step:hover:not(:disabled) {
89
+ background: var(--bg-subtle);
90
+ }
91
+ .rail-step:disabled {
92
+ cursor: default;
93
+ }
94
+
95
+ .rail-step-text {
96
+ display: inline-flex;
97
+ flex-wrap: wrap;
98
+ align-items: baseline;
99
+ gap: 0.35rem;
100
+ min-width: 0;
101
+ word-break: break-word;
102
+ }
103
+
104
+ .rail-step-active {
105
+ background: var(--accent-soft);
106
+ color: var(--text);
107
+ font-weight: 500;
108
+ }
109
+ </style>
@@ -0,0 +1,61 @@
1
+ <!--
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ -->
5
+
6
+ <script>
7
+ export let code = '';
8
+
9
+ function escapeHtml(s) {
10
+ return s
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ function highlight(raw) {
19
+ const escaped = escapeHtml(raw);
20
+ return escaped
21
+ .replace(/(&lt;!--[\s\S]*?--&gt;)/g, '<span class="tok-comment">$1</span>')
22
+ .replace(/(&lt;\/?)([a-zA-Z][a-zA-Z0-9-]*)/g, '$1<span class="tok-tag">$2</span>')
23
+ .replace(
24
+ /([a-zA-Z_:][a-zA-Z0-9_:.-]*)(=)(&quot;[^&]*&quot;)/g,
25
+ '<span class="tok-attr">$1</span>$2<span class="tok-value">$3</span>'
26
+ );
27
+ }
28
+
29
+ $: highlighted = highlight(code);
30
+ </script>
31
+
32
+ <pre class="code-viewer"><code>{@html highlighted}</code></pre>
33
+
34
+ <style>
35
+ .code-viewer {
36
+ margin: 0;
37
+ padding: 0.75rem 1rem;
38
+ background: var(--terminal-bg);
39
+ color: var(--terminal-text);
40
+ font-family: 'JetBrains Mono', monospace;
41
+ font-size: 0.78rem;
42
+ line-height: 1.5;
43
+ border-radius: var(--radius-md);
44
+ overflow-x: auto;
45
+ white-space: pre-wrap;
46
+ word-break: break-word;
47
+ }
48
+ :global(.code-viewer .tok-tag) {
49
+ color: var(--code-tag);
50
+ }
51
+ :global(.code-viewer .tok-attr) {
52
+ color: var(--code-attr);
53
+ }
54
+ :global(.code-viewer .tok-value) {
55
+ color: var(--code-string);
56
+ }
57
+ :global(.code-viewer .tok-comment) {
58
+ color: var(--code-comment);
59
+ font-style: italic;
60
+ }
61
+ </style>
@@ -27,7 +27,6 @@ export const SUITE_CASES_PER_PAGE = 20;
27
27
  export const COPY_TIMEOUT_MS = 1400;
28
28
  export const TOAST_TIMEOUT_MS = 4000;
29
29
 
30
- export const REPLAY_STEP_MS = 900;
31
30
  export const REDIRECT_DELAY_MS = 3000;
32
31
 
33
32
  export const WORKERS_MIN = 1;
@@ -18,6 +18,9 @@ export const TREND_HINT = '← older · newer →';
18
18
  export const NO_REPORTS_MESSAGE = 'No reports yet. Run a test to generate one.';
19
19
  export const SELECT_ALL_TITLE = 'Select all on this page';
20
20
  export const SELECT_ROW_TITLE = 'Select';
21
+ export const LEGACY_SCREENSHOTS_NOTICE =
22
+ 'Screenshots have been replaced by full session replay. Reports created before this change no longer have screenshots — steps and logs are still available.';
23
+ export const DISMISS_NOTICE_TITLE = 'Dismiss';
21
24
  export const DELETE_REPORT_TITLE = 'Delete report';
22
25
 
23
26
  export const deleteReportsTitle = (count) =>
@@ -43,17 +46,23 @@ export const RUN_LOGS_LABEL = 'Run Logs';
43
46
  export const RETRY_TITLE = 'Failed and was automatically retried before the final result';
44
47
  export const WATCH_REPLAY_TITLE = 'Watch replay';
45
48
  export const REPLAY_LABEL = 'Replay';
46
- export const SCREENSHOT_TOGGLE_LABEL = 'Screenshot';
47
- export const STEP_SCREENSHOT_ALT = 'Step screenshot';
48
- export const NO_SCREENSHOT_MESSAGE = 'No screenshot captured for this step';
49
49
 
50
50
  export const runnersBadge = (count) => `${count} runners`;
51
51
  export const casesCountLabel = (count) => `${count} cases`;
52
52
  export const attemptsLabel = (count) => `${count} attempts`;
53
53
  export const caseLabel = (index) => `Case ${index}`;
54
- export const replayScreenshotAlt = (index) => `Step ${index} screenshot`;
55
- export const pauseOrPlayTitle = (playing) => (playing ? 'Pause' : 'Play');
56
- export const replayCounter = (index, total) => `${index} / ${total}`;
54
+
55
+ // ── Recording replay ──
56
+ export const PLAYER_LOAD_ERROR = 'Could not load this recording.';
57
+ export const INSPECT_TOGGLE_LABEL = 'Inspect element';
58
+ export const RESTART_LABEL = 'Restart replay';
59
+ export const STEPS_RAIL_HEADING = 'Steps';
60
+ export const INSPECTOR_HEADING = 'Inspector';
61
+ export const NO_ELEMENT_SELECTED = 'Click an element in the replay to inspect it.';
62
+ export const ELEMENT_ATTRIBUTES_LABEL = 'Attributes';
63
+ export const ELEMENT_SIZE_LABEL = 'Size';
64
+ export const recordingTabLabel = (tabIndex) =>
65
+ tabIndex === 0 ? 'Main tab' : `Tab/Window ${tabIndex + 1}`;
57
66
 
58
67
  // ── Live run ──
59
68
  export const LIVE_PAGE_TITLE = 'Live Run — Plum';
@@ -68,8 +77,6 @@ export const CANCEL_RUN_LABEL = 'Cancel run';
68
77
  export const ALL_TESTS_PASSED = 'All tests passed';
69
78
  export const SOME_TESTS_FAILED = 'Some tests failed';
70
79
  export const VIEW_REPORT_NOW_LABEL = 'View Report Now';
71
- export const LIVE_STEP_LABEL = 'Step';
72
- export const LIVE_BROWSER_VIEW_ALT = 'Live browser view';
73
80
  export const AWAITING_STREAM_LABEL = 'Awaiting stream...';
74
81
  export const NO_STREAM_LABEL = 'No stream...';
75
82
  export const RUNNER_LABEL = 'Runner';
@@ -77,6 +84,9 @@ export const RUNNING_LABEL = 'Running…';
77
84
  export const FINISHED_LABEL = 'Finished';
78
85
  export const WAITING_FOR_OUTPUT = '(waiting for output…)';
79
86
 
87
+ export const UNKNOWN_RUNNER_LABEL = 'Unknown runner';
88
+ export const workerLabel = (id) => `Worker ${id}`;
89
+
80
90
  export const runsInProgressHeading = (count) =>
81
91
  count === 1 ? 'A run in progress' : `${count} runs in progress`;
82
92
  export const workersCountLabel = (count) => `${count} ${pluralize(count, 'worker')}`;
@@ -141,7 +141,7 @@ export const SLACK_WEBHOOK_HINT = 'Leave blank to disable Slack notifications';
141
141
  export const SLACK_WEBHOOK_PLACEHOLDER = 'https://hooks.slack.com/services/…';
142
142
  export const PUBLIC_URL_LABEL = 'Public URL';
143
143
  export const PUBLIC_URL_HINT =
144
- 'Base URL of this Plum instance, used to link reports in notifications';
144
+ 'Base URL of this Plum instance used to link reports in notifications, and by remote runner nodes to stream live test output back here';
145
145
  export const PUBLIC_URL_PLACEHOLDER = 'https://plum.yourcompany.com';
146
146
  export const INTEGRATIONS_SAVED_TOAST = 'Integration settings saved.';
147
147
  export const INTEGRATIONS_SAVE_FAILED = 'Failed to save integration settings.';
@@ -225,9 +225,14 @@ export const IMPORT_BLOCK_TITLE = 'Import';
225
225
  export const IMPORT_BLOCK_DESC =
226
226
  'Restores all data from a previously exported backup. Existing records are overwritten. Cron jobs are re-scheduled after import.';
227
227
  export const CHOOSE_FILE_LABEL = 'Choose file…';
228
- export const BACKUP_DISCLAIMER_PREFIX =
229
- 'Reports are not included in backups. To back up report history, run';
230
- export const BACKUP_DISCLAIMER_SUFFIX = 'directly on the PostgreSQL volume.';
228
+ export const INCLUDE_REPORTS_LABEL = 'Include reports & recordings';
229
+ export const INCLUDE_REPORTS_HINT =
230
+ 'Applies to both manual export and scheduled S3 backups. Can make backups significantly larger — recordings are session replays, not just screenshots.';
231
+ export const includeReportsDisclaimer = (included) =>
232
+ included
233
+ ? 'Reports and recordings are included in backups.'
234
+ : 'Reports are not included in backups — enable "Include reports & recordings" above, or run pg_dump directly on the PostgreSQL volume, to back up report history.';
235
+ export const saveIncludeReportsLabel = (saving) => (saving ? SAVING_LABEL : SAVE_LABEL);
231
236
 
232
237
  export const S3_STORAGE_CARD_TITLE = 'S3 Storage';
233
238
  export const S3_STORAGE_DESC_PREFIX =
@@ -261,6 +266,19 @@ const SECRET_KEY_SET_PLACEHOLDER = '••••••••';
261
266
  const SECRET_KEY_UNSET_PLACEHOLDER = 'Enter secret key';
262
267
  const TESTING_LABEL = 'Testing…';
263
268
 
269
+ export const RESTORE_FROM_S3_CARD_TITLE = 'Restore from S3';
270
+ export const RESTORE_FROM_S3_DESC =
271
+ 'Restores directly from a backup already uploaded to S3 — no need to download it yourself first. Existing records are overwritten. Cron jobs are re-scheduled after restore.';
272
+ export const CONFIGURE_S3_FIRST_RESTORE_MESSAGE = 'Configure S3 storage above to restore from it.';
273
+ export const NO_S3_BACKUPS_MESSAGE = 'No backups found at this bucket/prefix.';
274
+ export const REFRESH_LABEL = 'Refresh';
275
+ export const RESTORE_CONFIRM_TITLE = 'Restore this backup?';
276
+ export const restoreConfirmBody = (key) =>
277
+ `This will overwrite current cron jobs, test cases, test runs, users, runners, and project settings with the contents of "${key}". This cannot be undone.`;
278
+ export const RESTORE_SUCCESS_TOAST = 'Restored from S3 successfully.';
279
+ export const RESTORE_FAILED_FALLBACK = 'Restore failed.';
280
+ export const LIST_S3_BACKUPS_FAILED = 'Failed to list S3 backups.';
281
+
264
282
  export const SCHEDULED_BACKUP_CARD_TITLE = 'Scheduled Backup';
265
283
  export const CONFIGURE_S3_FIRST_MESSAGE = 'Configure S3 storage above to enable scheduled backups.';
266
284
  export const ENABLE_SCHEDULED_BACKUP_LABEL = 'Enable scheduled backup';
@@ -293,3 +311,6 @@ export const saveS3ConfigLabel = (saving) => (saving ? SAVING_LABEL : SAVE_S3_CO
293
311
  export const uploadedToLabel = (destination) => `uploaded to ${destination}`;
294
312
  export const uploadS3NowLabel = (running) => (running ? UPLOADING_LABEL : UPLOAD_TO_S3_NOW_LABEL);
295
313
  export const saveScheduleLabel = (saving) => (saving ? SAVING_LABEL : SAVE_SCHEDULE_LABEL);
314
+ export const restoreLabel = (restoring) => (restoring ? 'Restoring…' : 'Restore');
315
+ export const refreshingLabel = (loading) => (loading ? 'Loading…' : REFRESH_LABEL);
316
+ export const backupSizeLabel = (bytes) => `${(bytes / 1024).toFixed(1)} KB`;
@@ -13,23 +13,26 @@ export const SOCKET_EVENTS = Object.freeze({
13
13
  CANCEL_TEST: 'cancel-test',
14
14
  LOG: 'log',
15
15
  DONE: 'done',
16
- STEP_SCREENSHOT: 'step-screenshot',
17
16
 
18
17
  // Multi-lane distributed run (single interactive run, several runners)
19
18
  RUNNER_LANES_INIT: 'runner-lanes-init',
20
19
  RUNNER_LANE_LOG: 'runner-lane-log',
21
20
  RUNNER_LANE_STATUS: 'runner-lane-status',
22
- RUNNER_LANE_SCREENSHOT: 'runner-lane-screenshot',
23
21
 
24
22
  // Background runs (cron / REST / MCP triggered, no single owning socket)
25
23
  BG_RUN_START: 'bg-run-start',
26
24
  BG_RUN_LOG: 'bg-run-log',
27
25
  BG_RUN_DONE: 'bg-run-done',
28
- BG_RUN_SCREENSHOT: 'bg-run-screenshot',
29
26
  BG_RUN_LANES_INIT: 'bg-run-lanes-init',
30
27
  BG_RUN_LANE_LOG: 'bg-run-lane-log',
31
28
  BG_RUN_LANE_STATUS: 'bg-run-lane-status',
32
- BG_RUN_LANE_SCREENSHOT: 'bg-run-lane-screenshot',
29
+
30
+ // Live rrweb streaming — one shape for every run type, always
31
+ // carrying a lane id (BUILT_IN_RUNNER_ID for the plain single-run case) and
32
+ // a workerId, so a single built-in run with --parallel workers is finally
33
+ // attributable per worker instead of one flat interleaved stream.
34
+ RUNNER_LANE_RRWEB_BATCH: 'runner-lane-rrweb-batch',
35
+ BG_RUN_LANE_RRWEB_BATCH: 'bg-run-lane-rrweb-batch',
33
36
 
34
37
  // Global notifications (any client, not tied to a specific run)
35
38
  REPORT_READY: 'report-ready'
@@ -17,11 +17,34 @@ export const runnerState = writable({
17
17
  latestReportId: null, // number | null — set after test finishes
18
18
  status: 'idle', // 'idle' | 'running' | 'pass' | 'fail'
19
19
  lastRunId: '',
20
- lanes: [], // [{ id, name, testCount, status, logs, latestScreenshot }] multi-runner only
20
+ lanes: [], // [{ id, name, testCount, status, logs }] multi-runner only
21
21
  currentRun: null, // { tag, workers, browser, runners } — set while running
22
- latestScreenshot: null // { stepName, data: base64 } for single built-in runner
22
+ // { [laneId]: { [workerId]: { events: [] } } } always keyed by laneId even
23
+ // for a plain single-runner run (BUILT_IN_RUNNER_ID), so the live view's
24
+ // Runner/Worker tabs don't need a separate code path for that case.
25
+ rrwebByLane: {}
23
26
  });
24
27
 
28
+ // Merges a batch of rrweb events into the right lane/worker bucket, creating
29
+ // it on first sight — Svelte only re-renders on a *new* object/array
30
+ // reference, so this rebuilds the path down to the mutated bucket rather than
31
+ // pushing in place. Shared by runnerState (interactive) and backgroundRuns.
32
+ export function mergeRRwebBatch(rrwebByLane, { id: laneId, workerId, events }) {
33
+ const lane = rrwebByLane[laneId] ?? {};
34
+ const worker = lane[workerId] ?? { events: [] };
35
+ return {
36
+ ...rrwebByLane,
37
+ [laneId]: {
38
+ ...lane,
39
+ [workerId]: { events: [...worker.events, ...events] }
40
+ }
41
+ };
42
+ }
43
+
44
+ export function appendRRwebBatch(batch) {
45
+ runnerState.update((s) => ({ ...s, rrwebByLane: mergeRRwebBatch(s.rrwebByLane, batch) }));
46
+ }
47
+
25
48
  export const runnerConfig = writable({
26
49
  workers: 1,
27
50
  testID: '',
@@ -58,7 +81,7 @@ export function triggerRun(id, testRunId, notify = {}, runTitle = null) {
58
81
  lastRunId: runId,
59
82
  lanes: [],
60
83
  currentRun: { tag: runId, workers, browser, runners: selectedRunners, runTitle },
61
- latestScreenshot: null
84
+ rrwebByLane: {}
62
85
  });
63
86
  panelExpanded.set(true);
64
87
 
@@ -26,6 +26,13 @@
26
26
  --external-soft: #ccfbf1;
27
27
  --terminal-bg: #0d0c08;
28
28
  --terminal-text: #d6d0c8;
29
+ /* Fixed syntax-highlight colors for the always-dark code viewer (inspector
30
+ markup panel) — deliberately NOT the theme accent/node/pass tokens above,
31
+ which are tuned for light surfaces and go muddy against a dark one. */
32
+ --code-tag: #7dd3fc;
33
+ --code-attr: #fbbf24;
34
+ --code-string: #86efac;
35
+ --code-comment: #9ca3af;
29
36
 
30
37
  --font-display: 'Playfair Display', Georgia, serif;
31
38
  --font-body: 'DM Sans', system-ui, -apple-system, sans-serif;
@@ -137,6 +137,112 @@ export function parseRunnerLogs(logs) {
137
137
  return sections;
138
138
  }
139
139
 
140
- export function scenarioHasScreenshots(scenario) {
141
- return scenario.steps?.some((step) => step.screenshot) ?? false;
140
+ /**
141
+ * Buckets a report's features/scenarios by runner (lane) then worker
142
+ * (Cucumber --parallel process), preserving first-seen order. The innermost
143
+ * `features` array keeps the same shape as the flat list it replaces —
144
+ * callers should only show a group header when a level has more than one
145
+ * bucket.
146
+ */
147
+ export function groupScenariosByRunnerAndWorker(features) {
148
+ const runnerOrder = [];
149
+ const runnerMap = new Map();
150
+
151
+ for (const feature of features ?? []) {
152
+ for (const scenario of feature.scenarios ?? []) {
153
+ const runnerKey = scenario.runnerName ?? '';
154
+ const workerKey = scenario.workerId ?? 1;
155
+
156
+ if (!runnerMap.has(runnerKey)) {
157
+ runnerMap.set(runnerKey, {
158
+ runnerName: scenario.runnerName ?? null,
159
+ workerOrder: [],
160
+ workerMap: new Map()
161
+ });
162
+ runnerOrder.push(runnerKey);
163
+ }
164
+ const runnerEntry = runnerMap.get(runnerKey);
165
+
166
+ if (!runnerEntry.workerMap.has(workerKey)) {
167
+ runnerEntry.workerMap.set(workerKey, {
168
+ workerId: workerKey,
169
+ featureOrder: [],
170
+ featureMap: new Map()
171
+ });
172
+ runnerEntry.workerOrder.push(workerKey);
173
+ }
174
+ const workerEntry = runnerEntry.workerMap.get(workerKey);
175
+
176
+ if (!workerEntry.featureMap.has(feature.name)) {
177
+ workerEntry.featureMap.set(feature.name, { ...feature, scenarios: [] });
178
+ workerEntry.featureOrder.push(feature.name);
179
+ }
180
+ workerEntry.featureMap.get(feature.name).scenarios.push(scenario);
181
+ }
182
+ }
183
+
184
+ return runnerOrder.map((rk) => {
185
+ const r = runnerMap.get(rk);
186
+ return {
187
+ runnerName: r.runnerName,
188
+ workers: r.workerOrder.map((wk) => {
189
+ const w = r.workerMap.get(wk);
190
+ return {
191
+ workerId: w.workerId,
192
+ features: w.featureOrder.map((fn) => {
193
+ const f = w.featureMap.get(fn);
194
+ return { ...f, scenarioGroups: groupedScenarios(f.scenarios) };
195
+ })
196
+ };
197
+ })
198
+ };
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Turns a scenario's tab recordings into an ordered, non-overlapping timeline
204
+ * of {recordingId, from, to} segments (epoch ms), so a replay can auto-switch
205
+ * which tab it's showing instead of a manual tab strip. Recordings missing
206
+ * startedAt/endedAt (written before that field existed) are dropped — nothing
207
+ * to line up on a shared clock without them.
208
+ *
209
+ * Tabs are assumed to open/close like a stack (a popup nested inside the tab
210
+ * that opened it) — the only shape browser.ts's own tab tracking produces.
211
+ */
212
+ export function computeRecordingSegments(recordings) {
213
+ const usable = recordings.filter((r) => r.startedAt != null && r.endedAt != null);
214
+ if (usable.length === 0) return [];
215
+
216
+ const boundaries = usable.flatMap((r) => [
217
+ { ts: r.startedAt, kind: 'open', recording: r },
218
+ { ts: r.endedAt, kind: 'close', recording: r }
219
+ ]);
220
+ boundaries.sort((a, b) => a.ts - b.ts || (a.kind === 'open' ? -1 : 1));
221
+
222
+ const segments = [];
223
+ const stack = [];
224
+ let lastBoundary = boundaries[0].ts;
225
+
226
+ for (const b of boundaries) {
227
+ if (b.kind === 'open') {
228
+ const top = stack[stack.length - 1];
229
+ if (top && b.ts > lastBoundary) {
230
+ segments.push({ recordingId: top.id, from: lastBoundary, to: b.ts });
231
+ }
232
+ stack.push(b.recording);
233
+ lastBoundary = b.ts;
234
+ } else if (stack[stack.length - 1]?.id === b.recording.id) {
235
+ segments.push({ recordingId: b.recording.id, from: lastBoundary, to: b.ts });
236
+ stack.pop();
237
+ lastBoundary = b.ts;
238
+ }
239
+ // A close for a recording that isn't currently on top means it opened
240
+ // and closed while something nested inside it was still active — its
241
+ // own segment stays open until whatever's on top of it closes too.
242
+ }
243
+ const remaining = stack[stack.length - 1];
244
+ if (remaining && remaining.endedAt > lastBoundary) {
245
+ segments.push({ recordingId: remaining.id, from: lastBoundary, to: remaining.endedAt });
246
+ }
247
+ return segments;
142
248
  }
@@ -0,0 +1,34 @@
1
+ /*
2
+ * This file is part of Plum.
3
+ * Licensed under the MIT License. See LICENSE file in the project root for details.
4
+ */
5
+
6
+ function escapeAttr(v) {
7
+ return v.replace(/"/g, '&quot;');
8
+ }
9
+
10
+ function shallowMarkup(el) {
11
+ const tag = el.tagName.toLowerCase();
12
+ const attrs = Array.from(el.attributes ?? [])
13
+ .map((a) => ` ${a.name}="${escapeAttr(a.value)}"`)
14
+ .join('');
15
+ const childCount = el.children.length;
16
+ const text = childCount === 0 ? (el.textContent ?? '').trim() : '';
17
+ const inner =
18
+ childCount > 0
19
+ ? `\n <!-- ${childCount} child element${childCount === 1 ? '' : 's'} -->\n`
20
+ : text
21
+ ? `\n ${text.slice(0, 200)}\n`
22
+ : '';
23
+ return `<${tag}${attrs}>${inner}</${tag}>`;
24
+ }
25
+
26
+ /** Summarizes a DOM element for the replay inspector panel: markup preview, attributes, size. */
27
+ export function describeElement(el) {
28
+ const rect = el.getBoundingClientRect();
29
+ return {
30
+ markup: shallowMarkup(el),
31
+ attributes: Array.from(el.attributes ?? []).map((a) => ({ name: a.name, value: a.value })),
32
+ box: { width: Math.round(rect.width), height: Math.round(rect.height) }
33
+ };
34
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  <script>
7
7
  import { onMount, tick } from 'svelte';
8
+ import { slide } from 'svelte/transition';
8
9
  import { fetchReports, deleteReport, deleteReports, reportUrl } from '$lib/api/reports';
9
10
  import { reportsVersion } from '$lib/stores/runner';
10
11
  import { REPORTS_PER_PAGE, BROWSERS } from '$lib/constants';
@@ -23,6 +24,8 @@
23
24
  SELECT_ALL_TITLE,
24
25
  SELECT_ROW_TITLE,
25
26
  DELETE_REPORT_TITLE,
27
+ LEGACY_SCREENSHOTS_NOTICE,
28
+ DISMISS_NOTICE_TITLE,
26
29
  deleteReportsTitle,
27
30
  deleteReportsBody,
28
31
  runsRecorded,
@@ -43,6 +46,7 @@
43
46
  let selected = new Set();
44
47
  let deleteModal = { open: false, targets: [] };
45
48
  let deleting = false;
49
+ let showLegacyNotice = false;
46
50
 
47
51
  $: totalPages = Math.ceil(total / REPORTS_PER_PAGE);
48
52
  $: passRate = total ? Math.round((passCount / total) * 100) : 0;
@@ -75,9 +79,23 @@
75
79
  loadReports();
76
80
  }
77
81
 
78
- onMount(loadReports);
82
+ onMount(() => {
83
+ loadReports();
84
+ try {
85
+ showLegacyNotice = localStorage.getItem('plum:legacyScreenshotsNoticeDismissed') !== 'true';
86
+ } catch {
87
+ showLegacyNotice = true;
88
+ }
89
+ });
79
90
  $: if ($reportsVersion) loadReports();
80
91
 
92
+ function dismissLegacyNotice() {
93
+ showLegacyNotice = false;
94
+ try {
95
+ localStorage.setItem('plum:legacyScreenshotsNoticeDismissed', 'true');
96
+ } catch {}
97
+ }
98
+
81
99
  function toggleSelect(id, e) {
82
100
  e.preventDefault();
83
101
  e.stopPropagation();
@@ -139,6 +157,32 @@
139
157
  {deleteReportsBody(deleteModal.targets.length)}
140
158
  </ConfirmModal>
141
159
 
160
+ {#if showLegacyNotice}
161
+ <div class="legacy-notice" transition:slide={{ duration: 200 }}>
162
+ <p>{LEGACY_SCREENSHOTS_NOTICE}</p>
163
+ <button
164
+ class="legacy-notice-dismiss"
165
+ title={DISMISS_NOTICE_TITLE}
166
+ aria-label={DISMISS_NOTICE_TITLE}
167
+ on:click={dismissLegacyNotice}
168
+ >
169
+ <svg
170
+ width="14"
171
+ height="14"
172
+ viewBox="0 0 24 24"
173
+ fill="none"
174
+ stroke="currentColor"
175
+ stroke-width="2"
176
+ stroke-linecap="round"
177
+ stroke-linejoin="round"
178
+ >
179
+ <line x1="18" y1="6" x2="6" y2="18" />
180
+ <line x1="6" y1="6" x2="18" y2="18" />
181
+ </svg>
182
+ </button>
183
+ </div>
184
+ {/if}
185
+
142
186
  <div class="page-header">
143
187
  <div class="header-top">
144
188
  <div>
@@ -304,6 +348,40 @@
304
348
  {/if}
305
349
 
306
350
  <style>
351
+ .legacy-notice {
352
+ display: flex;
353
+ align-items: flex-start;
354
+ gap: 0.75rem;
355
+ background: var(--accent-soft);
356
+ border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
357
+ border-radius: var(--radius-sm);
358
+ padding: 0.75rem 0.875rem;
359
+ margin-bottom: 1.5rem;
360
+ }
361
+ .legacy-notice p {
362
+ flex: 1;
363
+ margin: 0;
364
+ font-size: 0.85rem;
365
+ line-height: 1.5;
366
+ color: var(--text);
367
+ }
368
+ .legacy-notice-dismiss {
369
+ display: inline-flex;
370
+ align-items: center;
371
+ justify-content: center;
372
+ flex-shrink: 0;
373
+ background: none;
374
+ border: none;
375
+ color: var(--text-muted);
376
+ cursor: pointer;
377
+ padding: 0.125rem;
378
+ border-radius: var(--radius-sm);
379
+ transition: color var(--duration-fast);
380
+ }
381
+ .legacy-notice-dismiss:hover {
382
+ color: var(--text);
383
+ }
384
+
307
385
  .page-header {
308
386
  margin-bottom: 2rem;
309
387
  padding-bottom: 1.5rem;