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.
@@ -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
- return setInterval(() => {
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
- }, 400);
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 result = nodeExecutionService.pollJob(req.params.jobId, offset);
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
- clearInterval(ssPoller);
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
- clearInterval(ssPoller);
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
- clearInterval(ssPoller);
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 the primary's HTTP polling
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(`${runner.url}/api/execute/${jobId}?offset=${logOffset}`, {
270
- headers: bearerHeader(runner.token),
271
- signal: AbortSignal.timeout(8000)
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 (!primaryUrl && body.logs) {
277
- onLog(body.logs);
278
- logOffset += body.logs.length;
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
- clearInterval(ssPoller);
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
- clearInterval(ssPoller);
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
- clearInterval(ssPoller);
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) instead of letterboxing. Re-run on
28
- // every stage resize, since the panel's own layout (tab strips appearing,
29
- // window resize) can still shift its size after the player is built.
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
- const nextTs = stepTimestamps[i + 1] ?? segments[segments.length - 1]?.to;
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
- if (doc && doc !== inspectAttachedDoc) {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.6",
3
+ "version": "2.9.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"