plum-e2e 2.9.6 → 2.9.7
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/lib/rrwebPoller.js +7 -2
- package/backend/routes/node.routes.js +2 -1
- package/backend/services/cronService.js +2 -2
- package/backend/services/nodeExecutionService.js +9 -4
- package/backend/services/runnerService.js +16 -7
- package/backend/services/triggerService.js +1 -1
- package/backend/websockets/socketHandler.js +2 -2
- package/frontend/src/lib/components/reports/LiveReplayer.svelte +3 -3
- package/frontend/src/lib/components/reports/RecordingPlayer.svelte +38 -2
- package/package.json +1 -1
|
@@ -11,7 +11,7 @@ const path = require('path');
|
|
|
11
11
|
// picked up here.
|
|
12
12
|
function startRRwebPoller(ssDir, onRRwebBatch) {
|
|
13
13
|
const seenFiles = new Set();
|
|
14
|
-
|
|
14
|
+
function drain() {
|
|
15
15
|
try {
|
|
16
16
|
const files = fs
|
|
17
17
|
.readdirSync(ssDir)
|
|
@@ -28,7 +28,12 @@ function startRRwebPoller(ssDir, onRRwebBatch) {
|
|
|
28
28
|
} catch {}
|
|
29
29
|
}
|
|
30
30
|
} catch {}
|
|
31
|
-
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const interval = setInterval(drain, 400);
|
|
34
|
+
// A scenario faster than one 400ms tick can exit before the interval ever
|
|
35
|
+
// fires — stop() drains once more so that last batch isn't dropped.
|
|
36
|
+
return { stop: () => (clearInterval(interval), drain()) };
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
module.exports = { startRRwebPoller };
|
|
@@ -60,7 +60,8 @@ router.get('/report/:jobId', authGuard, (req, res) => {
|
|
|
60
60
|
// Poll job status and streamed logs
|
|
61
61
|
router.get('/execute/:jobId', authGuard, (req, res) => {
|
|
62
62
|
const offset = parseInt(req.query.offset || '0', 10);
|
|
63
|
-
const
|
|
63
|
+
const rrwebOffset = parseInt(req.query.rrwebOffset || '0', 10);
|
|
64
|
+
const result = nodeExecutionService.pollJob(req.params.jobId, offset, rrwebOffset);
|
|
64
65
|
if (!result) return res.status(404).json({ error: 'Job not found' });
|
|
65
66
|
res.json(result);
|
|
66
67
|
});
|
|
@@ -101,7 +101,7 @@ function runSingleBuiltInAttempt({ taskName, currentTag, workers, browser, suppr
|
|
|
101
101
|
onLog(`[ERROR] ${d.toString()}`);
|
|
102
102
|
});
|
|
103
103
|
task.on('close', (code) => {
|
|
104
|
-
|
|
104
|
+
ssPoller.stop();
|
|
105
105
|
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
106
106
|
resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
|
|
107
107
|
});
|
|
@@ -379,7 +379,7 @@ async function runDistributed({
|
|
|
379
379
|
onLog(`[ERROR] ${d.toString()}`);
|
|
380
380
|
});
|
|
381
381
|
task.on('close', (code) => {
|
|
382
|
-
|
|
382
|
+
ssPoller.stop();
|
|
383
383
|
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
384
384
|
const raw = readCucumberReportFile() ?? '[]';
|
|
385
385
|
resolve({ code, rawJson: JSON.parse(raw) });
|
|
@@ -82,6 +82,9 @@ function startJob({
|
|
|
82
82
|
jobs[jobId] = {
|
|
83
83
|
status: JOB_STATUS.RUNNING,
|
|
84
84
|
logs: '',
|
|
85
|
+
// Drained by pollJob — the HTTP-poll fallback for a node with no reachable
|
|
86
|
+
// notifyPublicUrl to stream these back over a socket instead.
|
|
87
|
+
rrwebBatches: [],
|
|
85
88
|
exitCode: null,
|
|
86
89
|
startedAt: Date.now(),
|
|
87
90
|
meta: { tags: tags || '', browser, workers },
|
|
@@ -108,6 +111,7 @@ function startJob({
|
|
|
108
111
|
if (workers > 1) env.PARALLEL = String(workers);
|
|
109
112
|
|
|
110
113
|
const ssPoller = startRRwebPoller(ssDir, (batch) => {
|
|
114
|
+
jobs[jobId].rrwebBatches.push(batch);
|
|
111
115
|
primaryStream?.emit('rrweb-batch', batch);
|
|
112
116
|
});
|
|
113
117
|
|
|
@@ -123,7 +127,7 @@ function startJob({
|
|
|
123
127
|
primaryStream?.emit('log', text);
|
|
124
128
|
});
|
|
125
129
|
proc.on('close', (code) => {
|
|
126
|
-
|
|
130
|
+
ssPoller.stop();
|
|
127
131
|
primaryStream?.close();
|
|
128
132
|
jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
|
|
129
133
|
jobs[jobId].exitCode = code;
|
|
@@ -151,14 +155,15 @@ function startJob({
|
|
|
151
155
|
return jobId;
|
|
152
156
|
}
|
|
153
157
|
|
|
154
|
-
// Drains and returns logs since `offset` — used by
|
|
155
|
-
// loop (this node has no socket.io connection back).
|
|
156
|
-
function pollJob(jobId, offset) {
|
|
158
|
+
// Drains and returns logs/rrweb batches since `offset`/`rrwebOffset` — used by
|
|
159
|
+
// the primary's HTTP polling loop (this node has no socket.io connection back).
|
|
160
|
+
function pollJob(jobId, offset, rrwebOffset = 0) {
|
|
157
161
|
const job = jobs[jobId];
|
|
158
162
|
if (!job) return null;
|
|
159
163
|
return {
|
|
160
164
|
status: job.status,
|
|
161
165
|
logs: job.logs.slice(offset),
|
|
166
|
+
rrwebBatches: job.rrwebBatches.slice(rrwebOffset),
|
|
162
167
|
exitCode: job.exitCode
|
|
163
168
|
};
|
|
164
169
|
}
|
|
@@ -261,21 +261,30 @@ async function dispatchAndPoll(
|
|
|
261
261
|
}
|
|
262
262
|
|
|
263
263
|
let logOffset = 0;
|
|
264
|
+
let rrwebOffset = 0;
|
|
264
265
|
let polling = false;
|
|
265
266
|
const poll = setInterval(async () => {
|
|
266
267
|
if (polling) return;
|
|
267
268
|
polling = true;
|
|
268
269
|
try {
|
|
269
|
-
const res = await fetch(
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
const res = await fetch(
|
|
271
|
+
`${runner.url}/api/execute/${jobId}?offset=${logOffset}&rrwebOffset=${rrwebOffset}`,
|
|
272
|
+
{
|
|
273
|
+
headers: bearerHeader(runner.token),
|
|
274
|
+
signal: AbortSignal.timeout(8000)
|
|
275
|
+
}
|
|
276
|
+
);
|
|
273
277
|
if (!res.ok) return;
|
|
274
278
|
const body = await res.json();
|
|
275
279
|
|
|
276
|
-
if
|
|
277
|
-
|
|
278
|
-
|
|
280
|
+
// Skip if a socket relay is active — it already pushed these live.
|
|
281
|
+
if (!primaryUrl) {
|
|
282
|
+
if (body.logs) {
|
|
283
|
+
onLog(body.logs);
|
|
284
|
+
logOffset += body.logs.length;
|
|
285
|
+
}
|
|
286
|
+
for (const batch of body.rrwebBatches ?? []) onRRwebBatch?.(batch);
|
|
287
|
+
rrwebOffset += body.rrwebBatches?.length ?? 0;
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
|
|
@@ -79,7 +79,7 @@ function runAttempt({
|
|
|
79
79
|
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
80
80
|
|
|
81
81
|
proc.on('close', (code) => {
|
|
82
|
-
|
|
82
|
+
ssPoller.stop();
|
|
83
83
|
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
84
84
|
resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
|
|
85
85
|
});
|
|
@@ -251,7 +251,7 @@ function runBuiltInAttempt({
|
|
|
251
251
|
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
252
252
|
|
|
253
253
|
proc.on('close', (code) => {
|
|
254
|
-
|
|
254
|
+
ssPoller.stop();
|
|
255
255
|
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
256
256
|
activeProcs.delete(proc);
|
|
257
257
|
resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
|
|
@@ -553,7 +553,7 @@ async function runDistributed(
|
|
|
553
553
|
proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
|
|
554
554
|
|
|
555
555
|
proc.on('close', (code) => {
|
|
556
|
-
|
|
556
|
+
ssPoller.stop();
|
|
557
557
|
fs.rm(ssDir, { recursive: true, force: true }, () => {});
|
|
558
558
|
activeProcs.delete(proc);
|
|
559
559
|
const content =
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
let resizeObserver;
|
|
25
25
|
|
|
26
26
|
// rrweb-player renders at the recording's native resolution — scale-and-crop
|
|
27
|
-
// it to fill the stage (object-fit: cover)
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// it to fill the stage (object-fit: cover); letterboxing left visible bars
|
|
28
|
+
// when the aspect ratios didn't match. Re-run on every stage resize, since
|
|
29
|
+
// the panel's own layout can still shift its size after the player is built.
|
|
30
30
|
function updateScale() {
|
|
31
31
|
if (!nativeWidth || !nativeHeight) return;
|
|
32
32
|
const rect = stage.getBoundingClientRect();
|
|
@@ -28,6 +28,9 @@
|
|
|
28
28
|
const MIN_PLAYER_WIDTH = 480;
|
|
29
29
|
const MIN_PLAYER_HEIGHT = 320;
|
|
30
30
|
const CONTROLLER_HEIGHT = 80;
|
|
31
|
+
// Covers rrweb's 50ms 'finish' scheduling delay after a paused seek, so it's
|
|
32
|
+
// never misread as reaching a natural finish.
|
|
33
|
+
const FINISH_SUPPRESS_MS = 150;
|
|
31
34
|
// Player would otherwise exactly fill .player-stage, leaving the button row
|
|
32
35
|
// flush against its edge — shrinks it so centering leaves a margin.
|
|
33
36
|
const STAGE_BREATHING_ROOM = 24;
|
|
@@ -116,8 +119,16 @@
|
|
|
116
119
|
// — e.g. after toggling Inspect there may be nothing loaded to render.
|
|
117
120
|
// Rebuild instead so the right FullSnapshot gets loaded again.
|
|
118
121
|
if (targetIdx === activeSegmentIndex && ts >= mountedFirst) {
|
|
122
|
+
if (!autoplay) suppressFinishUntil = Date.now() + FINISH_SUPPRESS_MS;
|
|
119
123
|
player?.goto(ts - mountedFirst, autoplay);
|
|
120
124
|
if (stepIndexOverride !== undefined) currentStepIndex = stepIndexOverride;
|
|
125
|
+
// goto() can cross a mid-stream FullSnapshot (e.g. a tab navigating off
|
|
126
|
+
// about:blank) and silently reset the iframe document — re-attach.
|
|
127
|
+
if (inspecting) {
|
|
128
|
+
teardownInspectListeners();
|
|
129
|
+
setupInspectListeners();
|
|
130
|
+
startInspectWatch();
|
|
131
|
+
}
|
|
121
132
|
} else {
|
|
122
133
|
activeSegmentIndex = targetIdx;
|
|
123
134
|
buildPlayer({
|
|
@@ -134,7 +145,14 @@
|
|
|
134
145
|
if (stepTimestamps[i] === undefined || !player) return;
|
|
135
146
|
currentStepIndex = i;
|
|
136
147
|
// Jump to the next marker — step i's own marker fires before its actions run.
|
|
137
|
-
|
|
148
|
+
// The last step has none: use the segment's real last event, not its `to`
|
|
149
|
+
// boundary (can sit past it) — landing at/past the real end reads as a
|
|
150
|
+
// natural finish instead of a paused seek (see suppressFinishUntil).
|
|
151
|
+
let nextTs = stepTimestamps[i + 1];
|
|
152
|
+
if (nextTs === undefined) {
|
|
153
|
+
const { first, span } = segmentEventBounds(segments[segments.length - 1]);
|
|
154
|
+
nextTs = first + Math.max(0, span - 1);
|
|
155
|
+
}
|
|
138
156
|
if (nextTs === undefined) return;
|
|
139
157
|
seekToAbsolute(nextTs, false, i);
|
|
140
158
|
}
|
|
@@ -184,6 +202,14 @@
|
|
|
184
202
|
doc.addEventListener('click', onClick, true);
|
|
185
203
|
doc.addEventListener('mouseleave', onLeave);
|
|
186
204
|
doc.addEventListener('keydown', onKeydown);
|
|
205
|
+
// rrweb can rebuild the document in place (document.open()/write()) without
|
|
206
|
+
// the contentDocument reference changing — watchInspectDoc's reference
|
|
207
|
+
// check alone would miss that a fresh documentElement wiped this marker.
|
|
208
|
+
try {
|
|
209
|
+
doc.documentElement.dataset.plumInspectWired = '1';
|
|
210
|
+
} catch {
|
|
211
|
+
// cross-origin/detached doc — inspect wiring is best-effort
|
|
212
|
+
}
|
|
187
213
|
|
|
188
214
|
cleanupInspect = () => {
|
|
189
215
|
doc.removeEventListener('mousemove', onMove);
|
|
@@ -220,7 +246,8 @@
|
|
|
220
246
|
return;
|
|
221
247
|
}
|
|
222
248
|
const doc = currentReplayer()?.iframe?.contentDocument;
|
|
223
|
-
|
|
249
|
+
const stillWired = doc?.documentElement?.dataset.plumInspectWired === '1';
|
|
250
|
+
if (doc && (doc !== inspectAttachedDoc || !stillWired)) {
|
|
224
251
|
teardownInspectListeners();
|
|
225
252
|
setupInspectListeners();
|
|
226
253
|
}
|
|
@@ -277,6 +304,8 @@
|
|
|
277
304
|
// during a paused seek's sync catch-up. Only real autoplay should trigger
|
|
278
305
|
// the auto-advance-to-next-tab below.
|
|
279
306
|
let awaitingNaturalFinish = false;
|
|
307
|
+
// Set right before any deliberate paused seek — see FINISH_SUPPRESS_MS.
|
|
308
|
+
let suppressFinishUntil = 0;
|
|
280
309
|
|
|
281
310
|
// rrweb's 50ms finish timeout isn't cancelled by destroying the replayer —
|
|
282
311
|
// a short segment can be torn down before its own stale finish fires,
|
|
@@ -304,6 +333,7 @@
|
|
|
304
333
|
|
|
305
334
|
replayer.on('finish', () => {
|
|
306
335
|
if (buildGeneration !== myGeneration) return;
|
|
336
|
+
if (Date.now() < suppressFinishUntil) return;
|
|
307
337
|
if (!awaitingNaturalFinish) return;
|
|
308
338
|
if (activeSegmentIndex < segments.length - 1) {
|
|
309
339
|
const speed = replayer.config.speed;
|
|
@@ -462,12 +492,18 @@
|
|
|
462
492
|
player.play();
|
|
463
493
|
} else if (resumeState.finished) {
|
|
464
494
|
player.setSpeed(resumeState.speed);
|
|
495
|
+
// A fresh Player starts at its first frame — without this explicit seek,
|
|
496
|
+
// rebuilding here (e.g. toggling Inspect after a natural finish) would
|
|
497
|
+
// show a blank first frame instead of staying on the last one.
|
|
498
|
+
suppressFinishUntil = Date.now() + FINISH_SUPPRESS_MS;
|
|
499
|
+
player.goto(timeOffset, false);
|
|
465
500
|
finished = true;
|
|
466
501
|
livePosition = overallTo;
|
|
467
502
|
// Restart button moved under this rebuild — recompute its position.
|
|
468
503
|
requestAnimationFrame(() => positionRestartButton(playPauseButton()));
|
|
469
504
|
} else {
|
|
470
505
|
player.setSpeed(resumeState.speed);
|
|
506
|
+
if (resumeState.paused) suppressFinishUntil = Date.now() + FINISH_SUPPRESS_MS;
|
|
471
507
|
player.goto(timeOffset, !resumeState.paused);
|
|
472
508
|
}
|
|
473
509
|
|