plum-e2e 2.8.6 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/backend/_scaffold/utils/browser.ts +184 -30
  2. package/backend/_scaffold/utils/hooks.ts +37 -8
  3. package/backend/app.js +0 -5
  4. package/backend/config/scripts/generate-report.js +2 -2
  5. package/backend/constants/socketEvents.js +7 -4
  6. package/backend/lib/reportFilename.js +1 -2
  7. package/backend/lib/{screenshotPoller.js → rrwebPoller.js} +7 -4
  8. package/backend/lib/serverBootstrap.js +14 -0
  9. package/backend/logs/runner-cmtbz5b1l0000mr0110b27w5k.log +8 -0
  10. package/backend/mcp/server.js +3 -47
  11. package/backend/package-lock.json +199 -1
  12. package/backend/package.json +3 -1
  13. package/backend/prisma/migrations/20260828120000_add_recording_and_split_runner_worker_count/migration.sql +39 -0
  14. package/backend/prisma/migrations/20260828140000_add_recording_started_ended_at/migration.sql +5 -0
  15. package/backend/prisma/migrations/20260828150000_strip_screenshot_refs_from_reports/migration.sql +34 -0
  16. package/backend/prisma/migrations/20260828160000_add_backup_include_reports/migration.sql +4 -0
  17. package/backend/prisma/schema.prisma +97 -70
  18. package/backend/routes/backup.routes.js +47 -1
  19. package/backend/routes/reports.routes.js +23 -0
  20. package/backend/server.js +1 -1
  21. package/backend/services/backupCronService.js +1 -1
  22. package/backend/services/backupService.js +134 -44
  23. package/backend/services/cronService.js +23 -15
  24. package/backend/services/nodeExecutionService.js +41 -15
  25. package/backend/services/nodeStreamRegistry.js +24 -0
  26. package/backend/services/reportService.js +167 -83
  27. package/backend/services/runnerService.js +19 -7
  28. package/backend/services/settingsService.js +6 -3
  29. package/backend/services/triggerService.js +20 -13
  30. package/backend/websockets/nodeSocketHandler.js +40 -0
  31. package/backend/websockets/socketHandler.js +9 -7
  32. package/frontend/.svelte-kit/generated/server/internal.js +1 -1
  33. package/frontend/package-lock.json +121 -32
  34. package/frontend/package.json +1 -0
  35. package/frontend/src/lib/api/reports.js +13 -4
  36. package/frontend/src/lib/api/settings.js +16 -1
  37. package/frontend/src/lib/components/layout/RunnerPanel.svelte +9 -24
  38. package/frontend/src/lib/components/reports/ElementInspector.svelte +141 -0
  39. package/frontend/src/lib/components/reports/LiveReplayer.svelte +110 -0
  40. package/frontend/src/lib/components/reports/MultiTabTimeline.svelte +115 -0
  41. package/frontend/src/lib/components/reports/RecordingPlayer.svelte +786 -0
  42. package/frontend/src/lib/components/reports/StepsRail.svelte +109 -0
  43. package/frontend/src/lib/components/ui/CodeViewer.svelte +61 -0
  44. package/frontend/src/lib/constants.js +0 -1
  45. package/frontend/src/lib/copy/reports.js +18 -8
  46. package/frontend/src/lib/copy/settings.js +25 -4
  47. package/frontend/src/lib/socketEvents.js +7 -4
  48. package/frontend/src/lib/stores/runner.js +26 -3
  49. package/frontend/src/lib/styles/tokens.css +7 -0
  50. package/frontend/src/lib/utils/format.js +108 -2
  51. package/frontend/src/lib/utils/inspectElement.js +34 -0
  52. package/frontend/src/routes/reports/+page.svelte +79 -1
  53. package/frontend/src/routes/reports/[id]/+page.svelte +304 -495
  54. package/frontend/src/routes/reports/live/+page.svelte +236 -260
  55. package/frontend/src/routes/settings/+page.svelte +246 -8
  56. package/package.json +1 -1
  57. package/backend/playwright.config.js +0 -85
@@ -0,0 +1,786 @@
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 { onMount, onDestroy, tick } from 'svelte';
8
+ import Player from 'rrweb-player';
9
+ import 'rrweb-player/dist/style.css';
10
+ import { fetchRecordingEvents } from '$lib/api/reports';
11
+ import { computeRecordingSegments } from '$lib/utils/format';
12
+ import { describeElement } from '$lib/utils/inspectElement';
13
+ import StepsRail from './StepsRail.svelte';
14
+ import MultiTabTimeline from './MultiTabTimeline.svelte';
15
+ import ElementInspector from './ElementInspector.svelte';
16
+ import {
17
+ PLAYER_LOAD_ERROR,
18
+ INSPECT_TOGGLE_LABEL,
19
+ RESTART_LABEL,
20
+ recordingTabLabel
21
+ } from '$lib/copy/reports';
22
+
23
+ export let reportId;
24
+ export let recordings = [];
25
+ export let steps = [];
26
+ export let inspecting = false;
27
+
28
+ const MIN_PLAYER_WIDTH = 480;
29
+ const MIN_PLAYER_HEIGHT = 320;
30
+ const CONTROLLER_HEIGHT = 80;
31
+ // Player would otherwise exactly fill .player-stage, leaving the button row
32
+ // flush against its edge — shrinks it so centering leaves a margin.
33
+ const STAGE_BREATHING_ROOM = 24;
34
+
35
+ let stage;
36
+ let container;
37
+ let player = null;
38
+ let loading = true;
39
+ let loadError = false;
40
+ let selectedElement = null;
41
+ let hoverBox = null;
42
+ let cleanupInspect = null;
43
+ let inspectAttachedDoc = null;
44
+ let inspectWatchRaf = null;
45
+ let currentStepIndex = -1;
46
+ let stepTimestamps = [];
47
+
48
+ // Placed on one timeline so playback can auto-switch tabs — see computeRecordingSegments.
49
+ let recordingsById = new Map();
50
+ let eventsByRecordingId = new Map();
51
+ let segments = [];
52
+ let activeSegmentIndex = 0;
53
+ $: activeRecording = recordingsById.get(segments[activeSegmentIndex]?.recordingId);
54
+
55
+ // buildPlayer's mounted slice can start later than the segment's own `from`
56
+ // (see its headIdx search) — this is that slice's real local-zero.
57
+ // seekToAbsolute needs it to know whether an in-place goto() can reach a target.
58
+ let mountedFirst = 0;
59
+
60
+ // rrweb's own timeline resets per segment — this tracks absolute position
61
+ // continuously across every rebuild, feeding MultiTabTimeline (multi-tab
62
+ // only; single-tab's one player already has a correct native timeline).
63
+ let livePosition = 0;
64
+ let livePositionRaf = null;
65
+ function tickLivePosition() {
66
+ // Skip while finished: the finish handler snaps livePosition to overallTo
67
+ // (endedAt can sit past the last real event) — polling here would
68
+ // immediately overwrite that.
69
+ const replayer = currentReplayer();
70
+ if (replayer && !finished) livePosition = mountedFirst + replayer.getCurrentTime();
71
+ livePositionRaf = requestAnimationFrame(tickLivePosition);
72
+ }
73
+ $: overallFrom = segments[0]?.from ?? 0;
74
+ $: overallTo = segments[segments.length - 1]?.to ?? 0;
75
+
76
+ function currentReplayer() {
77
+ return player?.getReplayer?.();
78
+ }
79
+
80
+ // goto() offsets are relative to a recording's first event — bounded to this
81
+ // segment's own slice so a repeat appearance doesn't land in an earlier one.
82
+ function segmentEventBounds(seg) {
83
+ const events = eventsByRecordingId.get(seg?.recordingId) ?? [];
84
+ if (events.length === 0) return { first: 0, span: 0 };
85
+ const first = events[0].timestamp;
86
+ const upperBound = seg?.to ?? Infinity;
87
+ let last = first;
88
+ for (const e of events) {
89
+ if (e.timestamp > upperBound) break;
90
+ last = e.timestamp;
91
+ }
92
+ return { first, span: Math.max(0, last - first) };
93
+ }
94
+
95
+ function stepIndexAtAbsolute(ts) {
96
+ let idx = -1;
97
+ for (let i = 0; i < stepTimestamps.length; i++) {
98
+ if (stepTimestamps[i] > ts) break;
99
+ idx = i;
100
+ }
101
+ return idx;
102
+ }
103
+
104
+ // ts is the next step's marker, so deriving the highlight from it would pick
105
+ // the wrong step — see jumpToStep.
106
+ function seekToAbsolute(ts, autoplay, stepIndexOverride) {
107
+ let targetIdx = segments.findIndex((s) => ts >= s.from && ts <= s.to);
108
+ if (targetIdx === -1) targetIdx = segments.length - 1;
109
+ if (targetIdx < 0) return;
110
+
111
+ const { first, span } = segmentEventBounds(segments[targetIdx]);
112
+ const recordingOffset = Math.min(Math.max(0, ts - first), span);
113
+ const speed = currentReplayer()?.config.speed ?? 1;
114
+
115
+ // An in-place goto() only works if the mounted slice already covers `ts`
116
+ // — e.g. after toggling Inspect there may be nothing loaded to render.
117
+ // Rebuild instead so the right FullSnapshot gets loaded again.
118
+ if (targetIdx === activeSegmentIndex && ts >= mountedFirst) {
119
+ player?.goto(ts - mountedFirst, autoplay);
120
+ if (stepIndexOverride !== undefined) currentStepIndex = stepIndexOverride;
121
+ } else {
122
+ activeSegmentIndex = targetIdx;
123
+ buildPlayer({
124
+ finished: false,
125
+ timeOffset: recordingOffset,
126
+ paused: !autoplay,
127
+ speed,
128
+ stepIndexOverride
129
+ });
130
+ }
131
+ }
132
+
133
+ function jumpToStep(i) {
134
+ if (stepTimestamps[i] === undefined || !player) return;
135
+ currentStepIndex = i;
136
+ // Jump to the next marker — step i's own marker fires before its actions run.
137
+ const nextTs = stepTimestamps[i + 1] ?? segments[segments.length - 1]?.to;
138
+ if (nextTs === undefined) return;
139
+ seekToAbsolute(nextTs, false, i);
140
+ }
141
+
142
+ function setupInspectListeners() {
143
+ const replayer = currentReplayer();
144
+ const iframe = replayer?.iframe;
145
+ const doc = iframe?.contentDocument;
146
+ if (!iframe || !doc) return;
147
+ inspectAttachedDoc = doc;
148
+
149
+ const onMove = (e) => {
150
+ const target = e.target;
151
+ if (!target || target === doc.documentElement) {
152
+ hoverBox = null;
153
+ return;
154
+ }
155
+ const rect = target.getBoundingClientRect();
156
+ const iframeRect = iframe.getBoundingClientRect();
157
+ const stageRect = stage.getBoundingClientRect();
158
+ // rrweb-player scales the iframe to fit — clientWidth/Height are
159
+ // pre-scale, getBoundingClientRect post-scale; ratio = scale.
160
+ const scaleX = iframeRect.width / (iframe.clientWidth || 1);
161
+ const scaleY = iframeRect.height / (iframe.clientHeight || 1);
162
+ // Relative to .player-stage so its overflow:hidden clips the highlight.
163
+ hoverBox = {
164
+ top: iframeRect.top + rect.top * scaleY - stageRect.top,
165
+ left: iframeRect.left + rect.left * scaleX - stageRect.left,
166
+ width: rect.width * scaleX,
167
+ height: rect.height * scaleY
168
+ };
169
+ };
170
+ const onClick = (e) => {
171
+ e.preventDefault();
172
+ e.stopPropagation();
173
+ selectedElement = describeElement(e.target);
174
+ };
175
+ const onLeave = () => {
176
+ hoverBox = null;
177
+ };
178
+ // Escape fires here, not the outer window — the iframe is its own document.
179
+ const onKeydown = (e) => {
180
+ if (e.key === 'Escape') toggleInspect();
181
+ };
182
+
183
+ doc.addEventListener('mousemove', onMove);
184
+ doc.addEventListener('click', onClick, true);
185
+ doc.addEventListener('mouseleave', onLeave);
186
+ doc.addEventListener('keydown', onKeydown);
187
+
188
+ cleanupInspect = () => {
189
+ doc.removeEventListener('mousemove', onMove);
190
+ doc.removeEventListener('click', onClick, true);
191
+ doc.removeEventListener('mouseleave', onLeave);
192
+ doc.removeEventListener('keydown', onKeydown);
193
+ hoverBox = null;
194
+ };
195
+ }
196
+
197
+ function teardownInspectListeners() {
198
+ inspectAttachedDoc = null;
199
+ if (cleanupInspect) {
200
+ cleanupInspect();
201
+ cleanupInspect = null;
202
+ }
203
+ }
204
+
205
+ function stopInspectWatch() {
206
+ if (inspectWatchRaf !== null) {
207
+ cancelAnimationFrame(inspectWatchRaf);
208
+ inspectWatchRaf = null;
209
+ }
210
+ }
211
+
212
+ // Safety net alongside the immediate re-attach in buildPlayer: if the
213
+ // iframe's Document ever ends up different from the one our listeners
214
+ // are on (e.g. rrweb replacing it for a later FullSnapshot), self-heal
215
+ // on the next frame rather than leaving Inspect silently unresponsive
216
+ // until something else triggers a rebuild.
217
+ function watchInspectDoc() {
218
+ if (!inspecting) {
219
+ inspectWatchRaf = null;
220
+ return;
221
+ }
222
+ const doc = currentReplayer()?.iframe?.contentDocument;
223
+ if (doc && doc !== inspectAttachedDoc) {
224
+ teardownInspectListeners();
225
+ setupInspectListeners();
226
+ }
227
+ inspectWatchRaf = requestAnimationFrame(watchInspectDoc);
228
+ }
229
+
230
+ function startInspectWatch() {
231
+ if (inspectWatchRaf === null) inspectWatchRaf = requestAnimationFrame(watchInspectDoc);
232
+ }
233
+
234
+ // Rebuild since rrweb-player's canvas won't reflow to the resized stage on its own.
235
+ async function toggleInspect() {
236
+ inspecting = !inspecting;
237
+ const resumeState = currentPlaybackState();
238
+ if (resumeState && inspecting) resumeState.paused = true;
239
+ // jumpToStep pauses exactly at the NEXT marker to show step i's result —
240
+ // recomputing the highlight from that boundary would read it as step i+1
241
+ // having started. Preserve the already-correct index instead.
242
+ if (resumeState) resumeState.stepIndexOverride = currentStepIndex;
243
+ if (!inspecting) {
244
+ teardownInspectListeners();
245
+ stopInspectWatch();
246
+ }
247
+ await tick();
248
+ buildPlayer(resumeState);
249
+ }
250
+
251
+ function togglePlayPause() {
252
+ const replayer = currentReplayer();
253
+ if (!replayer) return;
254
+ if (replayer.service.state.matches('paused')) {
255
+ player.play();
256
+ } else {
257
+ player.pause();
258
+ }
259
+ }
260
+
261
+ // Escape exits inspect mode first — the parent's handler checks `inspecting` (bound) before closing.
262
+ function handleWindowKeydown(e) {
263
+ if (e.key === 'Escape' && inspecting) {
264
+ toggleInspect();
265
+ return;
266
+ }
267
+ if (e.key === ' ' && !inspecting) {
268
+ e.preventDefault();
269
+ togglePlayPause();
270
+ }
271
+ }
272
+
273
+ let finished = false;
274
+ let restartBoxStyle = '';
275
+
276
+ // rrweb schedules 'finish' 50ms after casting the array's last event — even
277
+ // during a paused seek's sync catch-up. Only real autoplay should trigger
278
+ // the auto-advance-to-next-tab below.
279
+ let awaitingNaturalFinish = false;
280
+
281
+ // rrweb's 50ms finish timeout isn't cancelled by destroying the replayer —
282
+ // a short segment can be torn down before its own stale finish fires,
283
+ // double-advancing past whatever's current. Each build gets a generation;
284
+ // a finish only acts if its replayer is still the live one.
285
+ let buildGeneration = 0;
286
+
287
+ function playPauseButton() {
288
+ return container?.querySelector('.rr-controller__btns button');
289
+ }
290
+
291
+ function positionRestartButton(btn) {
292
+ if (!btn) return;
293
+ const btnRect = btn.getBoundingClientRect();
294
+ const stageRect = stage.getBoundingClientRect();
295
+ restartBoxStyle = `top: ${btnRect.top - stageRect.top}px; left: ${btnRect.left - stageRect.left}px; width: ${btnRect.width}px; height: ${btnRect.height}px;`;
296
+ }
297
+
298
+ // Mutating rrweb's play/pause button directly duplicates the icon — overlay our own instead.
299
+ function setupFinishRestart() {
300
+ const replayer = currentReplayer();
301
+ const playPauseBtn = playPauseButton();
302
+ if (!replayer || !playPauseBtn) return;
303
+ const myGeneration = buildGeneration;
304
+
305
+ replayer.on('finish', () => {
306
+ if (buildGeneration !== myGeneration) return;
307
+ if (!awaitingNaturalFinish) return;
308
+ if (activeSegmentIndex < segments.length - 1) {
309
+ const speed = replayer.config.speed;
310
+ activeSegmentIndex += 1;
311
+ const nextSeg = segments[activeSegmentIndex];
312
+ const { first } = segmentEventBounds(nextSeg);
313
+ const timeOffset = Math.max(0, nextSeg.from - first);
314
+ buildPlayer({ finished: false, timeOffset, paused: false, speed });
315
+ return;
316
+ }
317
+ positionRestartButton(playPauseBtn);
318
+ finished = true;
319
+ // endedAt can sit past the last real event, so livePosition otherwise
320
+ // stalls short of overallTo on natural finish.
321
+ livePosition = overallTo;
322
+ });
323
+ // Derived from the replayer's own lifecycle, not our call sites — the
324
+ // native play/pause button calls rrweb's own toggle() directly,
325
+ // bypassing togglePlayPause(). A paused seek still nets out false:
326
+ // internally it's play() (emits start) then an explicit pause (emits pause).
327
+ replayer.on('start', () => {
328
+ finished = false;
329
+ awaitingNaturalFinish = true;
330
+ });
331
+ replayer.on('resume', () => {
332
+ finished = false;
333
+ awaitingNaturalFinish = true;
334
+ });
335
+ replayer.on('pause', () => {
336
+ awaitingNaturalFinish = false;
337
+ });
338
+ }
339
+
340
+ function restartPlayback() {
341
+ finished = false;
342
+ if (activeSegmentIndex !== 0) {
343
+ const speed = currentReplayer()?.config.speed ?? 1;
344
+ activeSegmentIndex = 0;
345
+ buildPlayer({ finished: false, timeOffset: 0, paused: false, speed });
346
+ } else {
347
+ player?.goto(0, true);
348
+ }
349
+ }
350
+
351
+ function currentPlaybackState() {
352
+ const replayer = currentReplayer();
353
+ if (!replayer) return null;
354
+ if (finished) return { finished: true, speed: replayer.config.speed };
355
+ // getCurrentTime() is relative to the mounted slice's first event, not
356
+ // the recording's true first — re-anchor to what buildPlayer expects,
357
+ // same as seekToAbsolute's mountedFirst.
358
+ const { first: recordingFirst } = segmentEventBounds(segments[activeSegmentIndex]);
359
+ return {
360
+ finished: false,
361
+ timeOffset: mountedFirst + replayer.getCurrentTime() - recordingFirst,
362
+ paused: replayer.service.state.matches('paused'),
363
+ speed: replayer.config.speed
364
+ };
365
+ }
366
+
367
+ function destroyPlayer() {
368
+ teardownInspectListeners();
369
+ if (player) {
370
+ try {
371
+ player.$destroy();
372
+ } catch {
373
+ // already torn down
374
+ }
375
+ player = null;
376
+ }
377
+ }
378
+
379
+ // rrweb-player's size is fixed at construction — rebuild to re-measure the stage.
380
+ function buildPlayer(resumeState = null) {
381
+ buildGeneration += 1;
382
+ const stageRect = stage.getBoundingClientRect();
383
+ const width = Math.max(MIN_PLAYER_WIDTH, Math.floor(stageRect.width));
384
+ // Subtract rrweb's controller height — MultiTabTimeline overlays into
385
+ // that same reserved strip, no extra space needed.
386
+ const height = Math.max(
387
+ MIN_PLAYER_HEIGHT,
388
+ Math.floor(stageRect.height) - CONTROLLER_HEIGHT - STAGE_BREATHING_ROOM
389
+ );
390
+
391
+ if (player) {
392
+ try {
393
+ player.$destroy();
394
+ } catch {
395
+ // already torn down
396
+ }
397
+ player = null;
398
+ }
399
+ container.replaceChildren();
400
+
401
+ const seg = segments[activeSegmentIndex];
402
+ const fullEvents = eventsByRecordingId.get(seg?.recordingId) ?? [];
403
+ // Truncate to this segment's own end so 'finish' fires at the real hand-off point.
404
+ const upperBound = seg?.to ?? Infinity;
405
+ const tailEvents = fullEvents.filter((e) => e.timestamp <= upperBound);
406
+ const recordingFirst = fullEvents[0]?.timestamp ?? 0;
407
+ const targetAbs = resumeState?.finished
408
+ ? upperBound
409
+ : recordingFirst + (resumeState?.timeOffset ?? 0);
410
+
411
+ // A recording with >1 FullSnapshot (an in-page navigation, or a tab still
412
+ // on about:blank when opened) breaks a paused goto() if it has to
413
+ // fast-forward across more than one — feed only from the last snapshot
414
+ // at or before the target.
415
+ let headIdx = 0;
416
+ for (let i = 0; i < tailEvents.length; i++) {
417
+ if (tailEvents[i].timestamp > targetAbs) break;
418
+ if (tailEvents[i].type === 2) headIdx = i > 0 && tailEvents[i - 1].type === 4 ? i - 1 : i;
419
+ }
420
+ // headIdx can reach into an earlier segment's span, dragging its step
421
+ // markers along as stray ticks on rrweb's timeline. Custom events never
422
+ // affect playback (sync catch-up skips them), so dropping ones before
423
+ // this segment only removes the stray ticks.
424
+ const events = tailEvents.slice(headIdx).filter((e) => e.type !== 5 || e.timestamp >= seg.from);
425
+ const first = events[0]?.timestamp ?? recordingFirst;
426
+ mountedFirst = first;
427
+ const timeOffset = Math.max(0, targetAbs - first);
428
+
429
+ player = new Player({
430
+ target: container,
431
+ props: {
432
+ events,
433
+ autoPlay: false,
434
+ showController: true,
435
+ speedOption: [0.5, 1, 2],
436
+ speed: 1,
437
+ width,
438
+ height
439
+ }
440
+ });
441
+ if (resumeState?.stepIndexOverride !== undefined) {
442
+ currentStepIndex = resumeState.stepIndexOverride;
443
+ } else {
444
+ currentStepIndex = stepIndexAtAbsolute(targetAbs);
445
+ }
446
+ player.addEventListener('custom-event', (event) => {
447
+ if (event?.data?.tag === 'step') {
448
+ currentStepIndex += 1;
449
+ }
450
+ });
451
+ setupFinishRestart();
452
+
453
+ if (!resumeState) {
454
+ // setSpeed(1) doesn't sync the controller's displayed speed — click the button instead.
455
+ requestAnimationFrame(() => {
456
+ if (!container) return;
457
+ const oneXBtn = Array.from(container.querySelectorAll('.rr-controller__btns button')).find(
458
+ (b) => b.textContent.trim() === '1x'
459
+ );
460
+ oneXBtn?.click();
461
+ });
462
+ player.play();
463
+ } else if (resumeState.finished) {
464
+ player.setSpeed(resumeState.speed);
465
+ finished = true;
466
+ livePosition = overallTo;
467
+ // Restart button moved under this rebuild — recompute its position.
468
+ requestAnimationFrame(() => positionRestartButton(playPauseButton()));
469
+ } else {
470
+ player.setSpeed(resumeState.speed);
471
+ player.goto(timeOffset, !resumeState.paused);
472
+ }
473
+
474
+ // Re-attach to the fresh iframe. watchInspectDoc (started below) keeps
475
+ // this correct afterward too, in case the iframe's Document changes
476
+ // again past this point.
477
+ if (inspecting) {
478
+ teardownInspectListeners();
479
+ setupInspectListeners();
480
+ startInspectWatch();
481
+ }
482
+ }
483
+
484
+ onMount(async () => {
485
+ recordingsById = new Map(recordings.map((r) => [r.id, r]));
486
+
487
+ try {
488
+ const results = await Promise.all(
489
+ recordings.map((r) => fetchRecordingEvents(reportId, r.id))
490
+ );
491
+ recordings.forEach((r, i) => eventsByRecordingId.set(r.id, results[i] ?? []));
492
+ } catch {
493
+ loadError = true;
494
+ loading = false;
495
+ return;
496
+ }
497
+ if ([...eventsByRecordingId.values()].every((events) => events.length === 0)) {
498
+ loadError = true;
499
+ loading = false;
500
+ return;
501
+ }
502
+
503
+ const computed = computeRecordingSegments(recordings);
504
+ // Older recordings lack startedAt/endedAt — fall back to just the first tab.
505
+ segments =
506
+ computed.length > 0
507
+ ? computed
508
+ : recordings[0]
509
+ ? [{ recordingId: recordings[0].id, from: 0, to: 0 }]
510
+ : [];
511
+
512
+ // Step markers are always written to the main tab (see markStepStart in browser.ts).
513
+ const mainRecording = recordings.find((r) => r.tabIndex === 0) ?? recordings[0];
514
+ const mainEvents = eventsByRecordingId.get(mainRecording?.id) ?? [];
515
+ stepTimestamps = mainEvents
516
+ .filter((e) => e.type === 5 && e.data?.tag === 'step')
517
+ .map((e) => e.timestamp);
518
+
519
+ buildPlayer();
520
+ loading = false;
521
+ livePositionRaf = requestAnimationFrame(tickLivePosition);
522
+ });
523
+
524
+ onDestroy(() => {
525
+ if (livePositionRaf !== null) cancelAnimationFrame(livePositionRaf);
526
+ stopInspectWatch();
527
+ destroyPlayer();
528
+ });
529
+ </script>
530
+
531
+ <svelte:window on:keydown={handleWindowKeydown} />
532
+
533
+ <div class="recording-player">
534
+ {#if steps.length > 0}
535
+ <StepsRail {steps} {stepTimestamps} {currentStepIndex} on:jump={(e) => jumpToStep(e.detail)} />
536
+ {/if}
537
+
538
+ <div class="player-stage" bind:this={stage}>
539
+ <div class="player-column">
540
+ <div
541
+ class="player-mount"
542
+ class:player-mount-multi={segments.length > 1}
543
+ bind:this={container}
544
+ ></div>
545
+ {#if segments.length > 1}
546
+ <MultiTabTimeline
547
+ from={overallFrom}
548
+ to={overallTo}
549
+ position={livePosition}
550
+ {stepTimestamps}
551
+ on:seek={(e) => seekToAbsolute(e.detail, false)}
552
+ />
553
+ {/if}
554
+ </div>
555
+ {#if segments.length > 1 && activeRecording}
556
+ <div class="active-tab-badge">{recordingTabLabel(activeRecording.tabIndex)}</div>
557
+ {/if}
558
+ {#if loading}
559
+ <div class="player-status">
560
+ <div class="loading-dots"><span></span><span></span><span></span></div>
561
+ </div>
562
+ {:else if loadError}
563
+ <div class="player-status">{PLAYER_LOAD_ERROR}</div>
564
+ {/if}
565
+ {#if hoverBox}
566
+ <div
567
+ class="inspect-highlight"
568
+ style="top: {hoverBox.top}px; left: {hoverBox.left}px; width: {hoverBox.width}px; height: {hoverBox.height}px;"
569
+ ></div>
570
+ {/if}
571
+ {#if finished}
572
+ <button
573
+ class="restart-overlay-btn"
574
+ style={restartBoxStyle}
575
+ on:click={restartPlayback}
576
+ aria-label={RESTART_LABEL}
577
+ >
578
+ <svg
579
+ width="14"
580
+ height="14"
581
+ viewBox="0 0 24 24"
582
+ fill="none"
583
+ stroke="currentColor"
584
+ stroke-width="2"
585
+ stroke-linecap="round"
586
+ stroke-linejoin="round"
587
+ >
588
+ <polyline points="1 4 1 10 7 10" />
589
+ <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
590
+ </svg>
591
+ </button>
592
+ {/if}
593
+
594
+ <button
595
+ class="inspect-fab"
596
+ class:inspect-fab-active={inspecting}
597
+ on:click={toggleInspect}
598
+ disabled={loading || loadError}
599
+ title={INSPECT_TOGGLE_LABEL}
600
+ aria-label={INSPECT_TOGGLE_LABEL}
601
+ >
602
+ <svg
603
+ width="14"
604
+ height="14"
605
+ viewBox="0 0 24 24"
606
+ fill="none"
607
+ stroke="currentColor"
608
+ stroke-width="2"
609
+ stroke-linecap="round"
610
+ stroke-linejoin="round"
611
+ >
612
+ <path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
613
+ </svg>
614
+ </button>
615
+ </div>
616
+
617
+ {#if inspecting}
618
+ <ElementInspector {selectedElement} />
619
+ {/if}
620
+ </div>
621
+
622
+ <style>
623
+ .recording-player {
624
+ flex: 1;
625
+ min-height: 0;
626
+ display: flex;
627
+ gap: 1px;
628
+ background: var(--border);
629
+ overflow: hidden;
630
+ }
631
+
632
+ /* ── Stage ── */
633
+ .player-stage {
634
+ position: relative;
635
+ flex: 1;
636
+ min-width: 0;
637
+ min-height: 0;
638
+ display: flex;
639
+ align-items: center;
640
+ justify-content: center;
641
+ /* Matches the constructed player's own white chrome so
642
+ STAGE_BREATHING_ROOM's margin doesn't look like an unstyled gap. */
643
+ background: var(--bg-elevated);
644
+ }
645
+
646
+ /* Shrink-wraps to .player-mount's own box so MultiTabTimeline can be
647
+ positioned absolutely against it, not the wider stage. */
648
+ .player-column {
649
+ position: relative;
650
+ display: inline-flex;
651
+ }
652
+
653
+ .player-mount {
654
+ display: flex;
655
+ }
656
+
657
+ /* rrweb's .rr-player/.rr-controller ship a rounded corner + drop shadow,
658
+ invisible only while the player filled its container edge-to-edge.
659
+ STAGE_BREATHING_ROOM now leaves a margin that reveals both as a smudge. */
660
+ .player-mount :global(.rr-player) {
661
+ border-radius: 0 !important;
662
+ box-shadow: none !important;
663
+ }
664
+ .player-mount :global(.rr-controller) {
665
+ border-radius: 0 !important;
666
+ }
667
+
668
+ /* rrweb sets pointer-events:none inline, blocking scroll — safe to override, iframe is sandboxed. */
669
+ .player-mount :global(iframe) {
670
+ pointer-events: auto !important;
671
+ }
672
+
673
+ /* "Skip inactive" is a dead control here — hide it. */
674
+ .player-mount :global(.switch) {
675
+ display: none !important;
676
+ }
677
+ .player-mount :global(.rr-controller__btns) {
678
+ gap: 0.5rem;
679
+ }
680
+
681
+ /* Multi-tab only: rrweb's timeline can't span multiple tabs (see
682
+ livePosition above) — visibility (not display) keeps its layout space
683
+ reserved for MultiTabTimeline to overlay into. */
684
+ .player-mount-multi :global(.rr-timeline) {
685
+ visibility: hidden !important;
686
+ pointer-events: none !important;
687
+ }
688
+
689
+ .player-status {
690
+ position: absolute;
691
+ inset: 0;
692
+ display: flex;
693
+ align-items: center;
694
+ justify-content: center;
695
+ color: var(--text-muted);
696
+ font-size: 0.85rem;
697
+ }
698
+
699
+ .loading-dots {
700
+ display: flex;
701
+ gap: 0.3rem;
702
+ }
703
+ .loading-dots span {
704
+ width: 6px;
705
+ height: 6px;
706
+ border-radius: 50%;
707
+ background: var(--text-muted);
708
+ animation: dotPulse 1.1s ease-in-out infinite;
709
+ }
710
+ .loading-dots span:nth-child(2) {
711
+ animation-delay: 0.15s;
712
+ }
713
+ .loading-dots span:nth-child(3) {
714
+ animation-delay: 0.3s;
715
+ }
716
+
717
+ .active-tab-badge {
718
+ position: absolute;
719
+ top: 0.75rem;
720
+ left: 0.75rem;
721
+ padding: 0.3rem 0.6rem;
722
+ background: rgb(0 0 0 / 0.55);
723
+ backdrop-filter: blur(6px);
724
+ border-radius: var(--radius-pill);
725
+ color: #fff;
726
+ font-family: 'JetBrains Mono', monospace;
727
+ font-size: 0.7rem;
728
+ }
729
+
730
+ .inspect-highlight {
731
+ position: absolute;
732
+ pointer-events: none;
733
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
734
+ border: 1.5px solid var(--accent);
735
+ border-radius: 2px;
736
+ z-index: 10000;
737
+ }
738
+
739
+ .restart-overlay-btn {
740
+ position: absolute;
741
+ display: flex;
742
+ align-items: center;
743
+ justify-content: center;
744
+ background: #fff;
745
+ border: none;
746
+ border-radius: 50%;
747
+ color: #11103e;
748
+ cursor: pointer;
749
+ z-index: 10001;
750
+ }
751
+ .restart-overlay-btn:hover {
752
+ background: #f0f0f5;
753
+ }
754
+
755
+ .inspect-fab {
756
+ position: absolute;
757
+ top: 0.75rem;
758
+ right: 0.75rem;
759
+ display: flex;
760
+ align-items: center;
761
+ justify-content: center;
762
+ width: 30px;
763
+ height: 30px;
764
+ background: rgb(0 0 0 / 0.55);
765
+ backdrop-filter: blur(6px);
766
+ border: none;
767
+ border-radius: 50%;
768
+ color: #fff;
769
+ cursor: pointer;
770
+ opacity: 0.7;
771
+ transition:
772
+ opacity var(--duration-fast) var(--ease-out),
773
+ background var(--duration-fast) var(--ease-out);
774
+ }
775
+ .inspect-fab:hover:not(:disabled) {
776
+ opacity: 1;
777
+ }
778
+ .inspect-fab:disabled {
779
+ opacity: 0.3;
780
+ cursor: default;
781
+ }
782
+ .inspect-fab-active {
783
+ background: var(--accent);
784
+ opacity: 1;
785
+ }
786
+ </style>