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.
- package/backend/_scaffold/utils/browser.ts +184 -30
- package/backend/_scaffold/utils/hooks.ts +37 -8
- package/backend/app.js +0 -5
- package/backend/config/scripts/generate-report.js +2 -2
- package/backend/constants/socketEvents.js +7 -4
- 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/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
|
@@ -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>
|