plum-e2e 2.9.6 → 2.9.8

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 };
@@ -43,12 +43,10 @@ function wireRealtimeServices(io, isNodeMode) {
43
43
  if (isNodeMode) return { cronService: null, backupCronService: null };
44
44
 
45
45
  const socketHandler = require('../websockets/socketHandler.js');
46
- const nodeSocketHandler = require('../websockets/nodeSocketHandler.js');
47
46
  const cronService = require('../services/cronService');
48
47
  const backupCronService = require('../services/backupCronService');
49
48
 
50
49
  socketHandler(io);
51
- nodeSocketHandler(io);
52
50
  cronService.setSocketIO(io);
53
51
  require('../routes/trigger.routes').setSocketIO(io);
54
52
 
@@ -0,0 +1,6 @@
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
+ 📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
6
+ Backend running on port 3055 (node/runner mode)
@@ -26,8 +26,7 @@
26
26
  "node-cron": "^3.0.3",
27
27
  "picocolors": "^1.1.1",
28
28
  "playwright": "1.50.1",
29
- "socket.io": "^4.8.1",
30
- "socket.io-client": "^4.8.3"
29
+ "socket.io": "^4.8.1"
31
30
  },
32
31
  "devDependencies": {
33
32
  "@playwright/test": "1.50.1",
@@ -2385,40 +2384,6 @@
2385
2384
  "node": ">=10.2.0"
2386
2385
  }
2387
2386
  },
2388
- "node_modules/engine.io-client": {
2389
- "version": "6.6.6",
2390
- "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
2391
- "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
2392
- "license": "MIT",
2393
- "dependencies": {
2394
- "@socket.io/component-emitter": "~3.1.0",
2395
- "debug": "~4.4.1",
2396
- "engine.io-parser": "~5.2.1",
2397
- "ws": "~8.21.0",
2398
- "xmlhttprequest-ssl": "~2.1.1"
2399
- }
2400
- },
2401
- "node_modules/engine.io-client/node_modules/ws": {
2402
- "version": "8.21.3",
2403
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
2404
- "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
2405
- "license": "MIT",
2406
- "engines": {
2407
- "node": ">=10.0.0"
2408
- },
2409
- "peerDependencies": {
2410
- "bufferutil": "^4.0.1",
2411
- "utf-8-validate": ">=5.0.2"
2412
- },
2413
- "peerDependenciesMeta": {
2414
- "bufferutil": {
2415
- "optional": true
2416
- },
2417
- "utf-8-validate": {
2418
- "optional": true
2419
- }
2420
- }
2421
- },
2422
2387
  "node_modules/engine.io-parser": {
2423
2388
  "version": "5.2.3",
2424
2389
  "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -4328,21 +4293,6 @@
4328
4293
  }
4329
4294
  }
4330
4295
  },
4331
- "node_modules/socket.io-client": {
4332
- "version": "4.8.3",
4333
- "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
4334
- "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
4335
- "license": "MIT",
4336
- "dependencies": {
4337
- "@socket.io/component-emitter": "~3.1.0",
4338
- "debug": "~4.4.1",
4339
- "engine.io-client": "~6.6.1",
4340
- "socket.io-parser": "~4.2.4"
4341
- },
4342
- "engines": {
4343
- "node": ">=10.0.0"
4344
- }
4345
- },
4346
4296
  "node_modules/socket.io-parser": {
4347
4297
  "version": "4.2.4",
4348
4298
  "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
@@ -4980,14 +4930,6 @@
4980
4930
  "node": ">=8.0"
4981
4931
  }
4982
4932
  },
4983
- "node_modules/xmlhttprequest-ssl": {
4984
- "version": "2.1.2",
4985
- "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
4986
- "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
4987
- "engines": {
4988
- "node": ">=0.4.0"
4989
- }
4990
- },
4991
4933
  "node_modules/yallist": {
4992
4934
  "version": "4.0.0",
4993
4935
  "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
@@ -39,7 +39,6 @@
39
39
  "node-cron": "^3.0.3",
40
40
  "picocolors": "^1.1.1",
41
41
  "playwright": "1.50.1",
42
- "socket.io": "^4.8.1",
43
- "socket.io-client": "^4.8.3"
42
+ "socket.io": "^4.8.1"
44
43
  }
45
44
  }
@@ -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
  });
@@ -19,6 +19,7 @@ import * as clack from '@clack/prompts';
19
19
  import pc from 'picocolors';
20
20
  import runnerProcess from '../lib/runnerProcess.js';
21
21
  import nodeRegister from '../lib/nodeRegister.js';
22
+ import globalRegistry from '../lib/globalRegistry.js';
22
23
 
23
24
  const {
24
25
  isLocalUrl,
@@ -37,13 +38,22 @@ const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
37
38
 
38
39
  const cancelled = (v) => clack.isCancel(v);
39
40
 
40
- // The mutating /runners routes accept a registered runner's own token in place
41
- // of an admin session this manager has no browser/JWT to present, but a node
42
- // already gets a token at registration time (see backend/middleware/jwtAuth.js
43
- // / runnerOrAdmin.js), so reuse that instead of a separate credential.
44
- const localToken = loadNodeConfig(process.cwd()).token;
41
+ // The mutating /runners routes take a registered runner's own token in place of
42
+ // an admin session (runnerOrAdmin.js), and this manager has no JWT so find
43
+ // any node token on this machine, falling back to PLUM_MCP_KEY.
44
+ function resolveAuthHeader() {
45
+ const cwdToken = loadNodeConfig(process.cwd()).token;
46
+ if (cwdToken) return { Authorization: `Bearer ${cwdToken}` };
47
+ for (const dir of globalRegistry.getInstalls('node')) {
48
+ const token = loadNodeConfig(dir).token;
49
+ if (token) return { Authorization: `Bearer ${token}` };
50
+ }
51
+ if (process.env.PLUM_MCP_KEY) return { Authorization: `ApiKey ${process.env.PLUM_MCP_KEY}` };
52
+ return {};
53
+ }
54
+ const AUTH_HEADER = resolveAuthHeader();
45
55
  function authHeaders() {
46
- return localToken ? { Authorization: `Bearer ${localToken}` } : {};
56
+ return AUTH_HEADER;
47
57
  }
48
58
 
49
59
  /**
@@ -291,6 +301,26 @@ async function runAction(r) {
291
301
  }
292
302
  }
293
303
 
304
+ // Polls a node's own /api/ping until it reports mode:'node' or the timeout
305
+ // elapses — used to confirm a runner actually serves before its registration
306
+ // is kept (see addRunner).
307
+ async function nodeIsUp(baseUrl, token, timeoutMs) {
308
+ const url = `${baseUrl.replace(/\/+$/, '')}/api/ping`;
309
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
310
+ const deadline = Date.now() + timeoutMs;
311
+ while (Date.now() < deadline) {
312
+ try {
313
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(3000) });
314
+ if (res.ok) {
315
+ const body = await res.json().catch(() => ({}));
316
+ if (body.ok && body.mode === 'node') return true;
317
+ }
318
+ } catch {}
319
+ await new Promise((r) => setTimeout(r, 750));
320
+ }
321
+ return false;
322
+ }
323
+
294
324
  async function addRunner() {
295
325
  const suggested = `node-${generateToken().slice(0, 6)}`;
296
326
 
@@ -301,12 +331,18 @@ async function addRunner() {
301
331
  });
302
332
  if (cancelled(name)) return;
303
333
 
304
- const port = await clack.text({
305
- message: 'Local port the node listens on',
306
- placeholder: '3002',
307
- defaultValue: '3002'
308
- });
309
- if (cancelled(port)) return;
334
+ let port;
335
+ for (;;) {
336
+ port = await clack.text({
337
+ message: 'Local port the node listens on',
338
+ placeholder: '3002',
339
+ defaultValue: '3002'
340
+ });
341
+ if (cancelled(port)) return;
342
+ const pid = findPidOnPort(Number(port));
343
+ if (!pid) break;
344
+ clack.log.warn(pc.yellow(`Port ${port} is already in use (pid ${pid}) — choose another.`));
345
+ }
310
346
 
311
347
  const defToken = process.env.NODE_TOKEN || generateToken();
312
348
  const token = await clack.text({
@@ -325,41 +361,75 @@ async function addRunner() {
325
361
  if (cancelled(urlInput)) return;
326
362
 
327
363
  const url = resolveNodeUrl(urlInput || defaultUrl);
364
+ const local = isLocalUrl(url);
328
365
 
329
366
  const s = clack.spinner();
330
367
  s.start(`Registering "${name}" with the primary...`);
331
- let id;
368
+ let id, reused;
332
369
  try {
333
- const res = await registerWithPrimary({
370
+ ({ id, reused } = await registerWithPrimary({
334
371
  primary: API_URL,
335
372
  name,
336
373
  url,
337
374
  token,
338
375
  browser: 'chromium'
339
- });
340
- id = res.id;
376
+ }));
341
377
  s.stop(
342
- res.reused
343
- ? pc.green(`Reusing existing runner "${name}"`)
344
- : pc.green(`Registered "${name}" (id ${id})`)
378
+ reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}"`)
345
379
  );
346
380
  } catch (e) {
347
381
  s.stop(pc.red(`Could not register "${name}": ${e.message}`));
348
382
  return;
349
383
  }
350
384
 
351
- prepareNodeEnv();
385
+ // Verify the node actually answers before keeping the registration — start a
386
+ // local one here, expect a remote one to already be up. A failed check rolls
387
+ // the registration back so no dead runner is left behind.
388
+ let entry = null;
389
+ let ok = false;
390
+ if (local) {
391
+ prepareNodeEnv();
392
+ entry = startNode({ id, port, token });
393
+ s.start(`Waiting for "${name}" to come up on port ${port}...`);
394
+ ok = await nodeIsUp(`http://localhost:${port}`, token, 20000);
395
+ s.stop(ok ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
396
+ } else {
397
+ s.start(`Checking for a Plum node at ${url}...`);
398
+ ok = await nodeIsUp(url, token, 8000);
399
+ s.stop(ok ? pc.green(`"${name}" is reachable`) : pc.red(`No Plum node answered at ${url}`));
400
+ }
352
401
 
353
- const startNow = await clack.confirm({ message: 'Start this runner now?' });
354
- if (!cancelled(startNow) && startNow) {
355
- const entry = startNode({ id, port, token });
356
- clack.log.success(pc.green(`Started "${name}" on port ${port} (pid ${entry.pid})`));
402
+ if (ok) {
403
+ clack.log.success(pc.green(`Runner "${name}" is ready.`));
404
+ return;
357
405
  }
406
+
407
+ if (entry) stopNode(id, Number(port));
408
+ if (!reused) {
409
+ try {
410
+ await deleteRunner(id);
411
+ } catch {}
412
+ }
413
+ if (entry?.logFile) clack.note(entry.logFile, 'Node log');
414
+ clack.log.warn(
415
+ pc.yellow(
416
+ reused
417
+ ? `"${name}" stays registered but is unreachable — check the port/URL.`
418
+ : `"${name}" was not registered — check the port/URL and try again.`
419
+ )
420
+ );
358
421
  }
359
422
 
360
423
  async function main() {
361
424
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Manage Runners ')));
362
425
 
426
+ if (!AUTH_HEADER.Authorization) {
427
+ clack.log.warn(
428
+ 'No runner credential found on this machine — stop / restart / delete will be rejected.\n' +
429
+ 'Run this from a folder where you started a node, or export PLUM_MCP_KEY.'
430
+ );
431
+ }
432
+
363
433
  for (;;) {
364
434
  const s = clack.spinner();
365
435
  s.start(`Loading runners from ${API_URL}...`);
@@ -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) });
@@ -8,7 +8,6 @@ const os = require('os');
8
8
  const path = require('path');
9
9
  const crypto = require('crypto');
10
10
  const { spawn } = require('child_process');
11
- const { io: ioClient } = require('socket.io-client');
12
11
  const { startRRwebPoller } = require('../lib/rrwebPoller');
13
12
  const { TRIGGER_REMOTE } = require('../constants/triggers');
14
13
  const { DEFAULT_BROWSER } = require('../constants/defaults');
@@ -24,40 +23,17 @@ function getJob(jobId) {
24
23
  return jobs[jobId];
25
24
  }
26
25
 
27
- // Opens this node's own outbound connection back to the primary for this job
28
- // — logs/rrweb events are pushed the moment they happen instead of waiting to
29
- // be polled. Uses the same token this node already validates incoming HTTP
30
- // calls against (see authGuard), so there's no separate credential to manage.
31
- // Best-effort: if the primary is unreachable this way, the job still runs —
32
- // dispatchAndPoll falls back to draining logs from the HTTP poll when it
33
- // never registered a socket relay for this jobId.
34
- function connectPrimaryStream(primaryUrl, jobId) {
35
- try {
36
- const socket = ioClient(`${primaryUrl}/node-stream`, {
37
- auth: { token: process.env.NODE_TOKEN },
38
- reconnectionAttempts: 5,
39
- timeout: 8000
40
- });
41
- socket.on('connect', () => socket.emit('join', jobId));
42
- return socket;
43
- } catch {
44
- return null;
45
- }
46
- }
47
-
48
26
  // Starts a remote test job dispatched from the primary server: materializes
49
- // any uploaded test files, spawns `npm run test`, and tracks logs for later
50
- // HTTP polling (see pollJob).
27
+ // any uploaded test files, spawns `npm run test`, and tracks logs + rrweb
28
+ // batches for HTTP polling (see pollJob).
51
29
  function startJob({
52
30
  tags,
53
31
  browser = DEFAULT_BROWSER,
54
32
  workers = 1,
55
33
  tests = null,
56
- env: userEnv = {},
57
- primaryUrl = null
34
+ env: userEnv = {}
58
35
  }) {
59
36
  const jobId = crypto.randomUUID();
60
- const primaryStream = primaryUrl ? connectPrimaryStream(primaryUrl, jobId) : null;
61
37
 
62
38
  // path.resolve ensures absolute even if TMPDIR env var is set to a relative path
63
39
  const tmpdir = path.resolve(os.tmpdir());
@@ -82,6 +58,8 @@ function startJob({
82
58
  jobs[jobId] = {
83
59
  status: JOB_STATUS.RUNNING,
84
60
  logs: '',
61
+ // Both drained by pollJob — the primary has no socket back to this node.
62
+ rrwebBatches: [],
85
63
  exitCode: null,
86
64
  startedAt: Date.now(),
87
65
  meta: { tags: tags || '', browser, workers },
@@ -108,23 +86,18 @@ function startJob({
108
86
  if (workers > 1) env.PARALLEL = String(workers);
109
87
 
110
88
  const ssPoller = startRRwebPoller(ssDir, (batch) => {
111
- primaryStream?.emit('rrweb-batch', batch);
89
+ jobs[jobId].rrwebBatches.push(batch);
112
90
  });
113
91
 
114
92
  const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
115
93
  proc.stdout.on('data', (d) => {
116
- const text = d.toString();
117
- jobs[jobId].logs += text;
118
- primaryStream?.emit('log', text);
94
+ jobs[jobId].logs += d.toString();
119
95
  });
120
96
  proc.stderr.on('data', (d) => {
121
- const text = d.toString();
122
- jobs[jobId].logs += text;
123
- primaryStream?.emit('log', text);
97
+ jobs[jobId].logs += d.toString();
124
98
  });
125
99
  proc.on('close', (code) => {
126
- clearInterval(ssPoller);
127
- primaryStream?.close();
100
+ ssPoller.stop();
128
101
  jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
129
102
  jobs[jobId].exitCode = code;
130
103
 
@@ -151,14 +124,15 @@ function startJob({
151
124
  return jobId;
152
125
  }
153
126
 
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) {
127
+ // Drains and returns logs/rrweb batches since `offset`/`rrwebOffset` — used by
128
+ // the primary's HTTP polling loop (this node has no socket.io connection back).
129
+ function pollJob(jobId, offset, rrwebOffset = 0) {
157
130
  const job = jobs[jobId];
158
131
  if (!job) return null;
159
132
  return {
160
133
  status: job.status,
161
134
  logs: job.logs.slice(offset),
135
+ rrwebBatches: job.rrwebBatches.slice(rrwebOffset),
162
136
  exitCode: job.exitCode
163
137
  };
164
138
  }
@@ -11,8 +11,6 @@ const { BUILT_IN_RUNNER_ID } = require('../constants/triggers');
11
11
  const { DEFAULT_BROWSER } = require('../constants/defaults');
12
12
  const { bearerHeader } = require('../lib/authHeader');
13
13
  const { JOB_STATUS } = require('../constants/jobStatus');
14
- const settingsService = require('./settingsService');
15
- const nodeStreamRegistry = require('./nodeStreamRegistry');
16
14
 
17
15
  // ---------------------------------------------------------------------------
18
16
  // Runner CRUD
@@ -209,7 +207,6 @@ async function dispatchAndPoll(
209
207
  const finish = (code, content) => {
210
208
  if (settled) return;
211
209
  settled = true;
212
- if (jobId) nodeStreamRegistry.unregisterRelay(jobId);
213
210
  onDone(code, content);
214
211
  };
215
212
 
@@ -220,12 +217,6 @@ async function dispatchAndPoll(
220
217
  return;
221
218
  }
222
219
 
223
- // The node needs to know where to open its own socket back to us — without
224
- // this it has no way to reach the primary, since today only the reverse
225
- // (primary knowing each node's url) is configured.
226
- const { notifyPublicUrl } = await settingsService.getWebhooks();
227
- const primaryUrl = notifyPublicUrl ? notifyPublicUrl.replace(/\/$/, '') : null;
228
-
229
220
  let jobId;
230
221
  try {
231
222
  const res = await fetch(`${runner.url}/api/execute`, {
@@ -239,8 +230,7 @@ async function dispatchAndPoll(
239
230
  browser,
240
231
  workers,
241
232
  tests: collectTestFiles(),
242
- env: loadTestEnv(process.cwd()),
243
- ...(primaryUrl ? { primaryUrl } : {})
233
+ env: loadTestEnv(process.cwd())
244
234
  }),
245
235
  signal: AbortSignal.timeout(10000)
246
236
  });
@@ -254,29 +244,31 @@ async function dispatchAndPoll(
254
244
 
255
245
  onLog(`Connected to runner "${runner.name}" — job ${jobId}\n`);
256
246
 
257
- // With a primaryUrl configured, the node streams logs/rrweb events over its
258
- // own socket instead — draining them from the poll too would duplicate them.
259
- if (primaryUrl) {
260
- nodeStreamRegistry.registerRelay(jobId, { onRRwebBatch, onLog });
261
- }
262
-
263
247
  let logOffset = 0;
248
+ let rrwebOffset = 0;
264
249
  let polling = false;
250
+ // Tight interval: the live viewer reads logs and rrweb batches straight off
251
+ // this poll — primary→node, so nothing has to connect the other way.
265
252
  const poll = setInterval(async () => {
266
253
  if (polling) return;
267
254
  polling = true;
268
255
  try {
269
- const res = await fetch(`${runner.url}/api/execute/${jobId}?offset=${logOffset}`, {
270
- headers: bearerHeader(runner.token),
271
- signal: AbortSignal.timeout(8000)
272
- });
256
+ const res = await fetch(
257
+ `${runner.url}/api/execute/${jobId}?offset=${logOffset}&rrwebOffset=${rrwebOffset}`,
258
+ {
259
+ headers: bearerHeader(runner.token),
260
+ signal: AbortSignal.timeout(8000)
261
+ }
262
+ );
273
263
  if (!res.ok) return;
274
264
  const body = await res.json();
275
265
 
276
- if (!primaryUrl && body.logs) {
266
+ if (body.logs) {
277
267
  onLog(body.logs);
278
268
  logOffset += body.logs.length;
279
269
  }
270
+ for (const batch of body.rrwebBatches ?? []) onRRwebBatch?.(batch);
271
+ rrwebOffset += body.rrwebBatches?.length ?? 0;
280
272
 
281
273
  if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
282
274
  clearInterval(poll);
@@ -288,7 +280,7 @@ async function dispatchAndPoll(
288
280
  } finally {
289
281
  polling = false;
290
282
  }
291
- }, 2500);
283
+ }, 1000);
292
284
  }
293
285
 
294
286
  module.exports = {
@@ -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.8",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"
@@ -1,24 +0,0 @@
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
- // In-memory jobId -> relay callbacks, so an incoming node socket (which only
7
- // knows its own jobId) can find where to forward live rrweb/log events —
8
- // registered by whichever dispatch call (socketHandler, cronService) started
9
- // this job and already knows the browser-facing emit target/laneId.
10
- const relays = new Map();
11
-
12
- function registerRelay(jobId, { onRRwebBatch, onLog }) {
13
- relays.set(jobId, { onRRwebBatch, onLog });
14
- }
15
-
16
- function unregisterRelay(jobId) {
17
- relays.delete(jobId);
18
- }
19
-
20
- function getRelay(jobId) {
21
- return relays.get(jobId);
22
- }
23
-
24
- module.exports = { registerRelay, unregisterRelay, getRelay };
@@ -1,40 +0,0 @@
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
- const runnerService = require('../services/runnerService');
7
- const nodeStreamRegistry = require('../services/nodeStreamRegistry');
8
-
9
- // A separate namespace from the browser-facing default one — a node connects
10
- // with its own runner token (the same credential it already uses to call back
11
- // into the primary's HTTP control routes, see runnerService.isValidToken) and
12
- // announces the jobId it's streaming for, which nodeStreamRegistry maps back
13
- // to whichever dispatch call is waiting on it.
14
- function nodeSocketHandler(io) {
15
- const nodeIo = io.of('/node-stream');
16
-
17
- nodeIo.use(async (socket, next) => {
18
- const token = socket.handshake.auth?.token;
19
- if (await runnerService.isValidToken(token)) return next();
20
- next(new Error('unauthorized'));
21
- });
22
-
23
- nodeIo.on('connection', (socket) => {
24
- let jobId = null;
25
-
26
- socket.on('join', (id) => {
27
- jobId = id;
28
- });
29
-
30
- socket.on('rrweb-batch', (batch) => {
31
- if (jobId) nodeStreamRegistry.getRelay(jobId)?.onRRwebBatch?.(batch);
32
- });
33
-
34
- socket.on('log', (text) => {
35
- if (jobId) nodeStreamRegistry.getRelay(jobId)?.onLog?.(text);
36
- });
37
- });
38
- }
39
-
40
- module.exports = nodeSocketHandler;