plum-e2e 2.9.7 → 2.9.9

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.
@@ -201,6 +201,29 @@ function prepareEnv() {
201
201
  });
202
202
  }
203
203
 
204
+ /**
205
+ * Polls a node's own /api/ping (at `baseUrl`, e.g. http://localhost:3002) until
206
+ * it reports mode:'node', or the timeout elapses. A detached spawn returns a
207
+ * pid immediately even when the process goes on to die on EADDRINUSE — callers
208
+ * use this to report honestly whether the node actually came up.
209
+ */
210
+ async function nodeReachable(baseUrl, token, timeoutMs = 15000) {
211
+ const url = `${String(baseUrl).replace(/\/+$/, '')}/api/ping`;
212
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
213
+ const deadline = Date.now() + timeoutMs;
214
+ while (Date.now() < deadline) {
215
+ try {
216
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(3000) });
217
+ if (res.ok) {
218
+ const body = await res.json().catch(() => ({}));
219
+ if (body.ok && body.mode === 'node') return true;
220
+ }
221
+ } catch {}
222
+ await new Promise((r) => setTimeout(r, 500));
223
+ }
224
+ return false;
225
+ }
226
+
204
227
  /**
205
228
  * Spawns a detached node-mode server for the given runner and records its pid.
206
229
  * Returns the registry entry.
@@ -316,6 +339,7 @@ module.exports = {
316
339
  parsePort,
317
340
  findPidOnPort,
318
341
  killPort,
342
+ nodeReachable,
319
343
  pruneDead,
320
344
  statusOf,
321
345
  ensureBackendDeps,
@@ -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
  }
@@ -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,
@@ -29,7 +30,8 @@ const {
29
30
  startNode,
30
31
  stopNode,
31
32
  findPidOnPort,
32
- killPort
33
+ killPort,
34
+ nodeReachable
33
35
  } = runnerProcess;
34
36
  const { generateToken, registerWithPrimary, detectLanIp, loadNodeConfig } = nodeRegister;
35
37
 
@@ -37,13 +39,23 @@ const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
37
39
 
38
40
  const cancelled = (v) => clack.isCancel(v);
39
41
 
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;
42
+ // The mutating /runners routes accept any registered runner's own token in
43
+ // place of an admin session (runnerOrAdmin.js). Use one: handed in by `plum`
44
+ // (which reads it from the primary's DB when run on the server host), or from
45
+ // a node's own .plum-node.json this folder, then any other node install.
46
+ function resolveRunnerToken() {
47
+ if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
48
+ const cwdToken = loadNodeConfig(process.cwd()).token;
49
+ if (cwdToken) return cwdToken;
50
+ for (const dir of globalRegistry.getInstalls('node')) {
51
+ const token = loadNodeConfig(dir).token;
52
+ if (token) return token;
53
+ }
54
+ return null;
55
+ }
56
+ const RUNNER_TOKEN = resolveRunnerToken();
45
57
  function authHeaders() {
46
- return localToken ? { Authorization: `Bearer ${localToken}` } : {};
58
+ return RUNNER_TOKEN ? { Authorization: `Bearer ${RUNNER_TOKEN}` } : {};
47
59
  }
48
60
 
49
61
  /**
@@ -301,12 +313,18 @@ async function addRunner() {
301
313
  });
302
314
  if (cancelled(name)) return;
303
315
 
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;
316
+ let port;
317
+ for (;;) {
318
+ port = await clack.text({
319
+ message: 'Local port the node listens on',
320
+ placeholder: '3002',
321
+ defaultValue: '3002'
322
+ });
323
+ if (cancelled(port)) return;
324
+ const pid = findPidOnPort(Number(port));
325
+ if (!pid) break;
326
+ clack.log.warn(pc.yellow(`Port ${port} is already in use (pid ${pid}) — choose another.`));
327
+ }
310
328
 
311
329
  const defToken = process.env.NODE_TOKEN || generateToken();
312
330
  const token = await clack.text({
@@ -325,41 +343,75 @@ async function addRunner() {
325
343
  if (cancelled(urlInput)) return;
326
344
 
327
345
  const url = resolveNodeUrl(urlInput || defaultUrl);
346
+ const local = isLocalUrl(url);
328
347
 
329
348
  const s = clack.spinner();
330
349
  s.start(`Registering "${name}" with the primary...`);
331
- let id;
350
+ let id, reused;
332
351
  try {
333
- const res = await registerWithPrimary({
352
+ ({ id, reused } = await registerWithPrimary({
334
353
  primary: API_URL,
335
354
  name,
336
355
  url,
337
356
  token,
338
357
  browser: 'chromium'
339
- });
340
- id = res.id;
358
+ }));
341
359
  s.stop(
342
- res.reused
343
- ? pc.green(`Reusing existing runner "${name}"`)
344
- : pc.green(`Registered "${name}" (id ${id})`)
360
+ reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}"`)
345
361
  );
346
362
  } catch (e) {
347
363
  s.stop(pc.red(`Could not register "${name}": ${e.message}`));
348
364
  return;
349
365
  }
350
366
 
351
- prepareNodeEnv();
367
+ // Verify the node actually answers before keeping the registration — start a
368
+ // local one here, expect a remote one to already be up. A failed check rolls
369
+ // the registration back so no dead runner is left behind.
370
+ let entry = null;
371
+ let ok = false;
372
+ if (local) {
373
+ prepareNodeEnv();
374
+ entry = startNode({ id, port, token });
375
+ s.start(`Waiting for "${name}" to come up on port ${port}...`);
376
+ ok = await nodeReachable(`http://localhost:${port}`, token, 20000);
377
+ s.stop(ok ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
378
+ } else {
379
+ s.start(`Checking for a Plum node at ${url}...`);
380
+ ok = await nodeReachable(url, token, 8000);
381
+ s.stop(ok ? pc.green(`"${name}" is reachable`) : pc.red(`No Plum node answered at ${url}`));
382
+ }
383
+
384
+ if (ok) {
385
+ clack.log.success(pc.green(`Runner "${name}" is ready.`));
386
+ return;
387
+ }
352
388
 
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})`));
389
+ if (entry) stopNode(id, Number(port));
390
+ if (!reused) {
391
+ try {
392
+ await deleteRunner(id);
393
+ } catch {}
357
394
  }
395
+ if (entry?.logFile) clack.note(entry.logFile, 'Node log');
396
+ clack.log.warn(
397
+ pc.yellow(
398
+ reused
399
+ ? `"${name}" stays registered but is unreachable — check the port/URL.`
400
+ : `"${name}" was not registered — check the port/URL and try again.`
401
+ )
402
+ );
358
403
  }
359
404
 
360
405
  async function main() {
361
406
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Manage Runners ')));
362
407
 
408
+ if (!RUNNER_TOKEN) {
409
+ clack.log.warn(
410
+ 'No runner token available — stop / restart / delete will be rejected.\n' +
411
+ 'Run `plum manage-runners` on the primary host, or from a folder where you started a node.'
412
+ );
413
+ }
414
+
363
415
  for (;;) {
364
416
  const s = clack.spinner();
365
417
  s.start(`Loading runners from ${API_URL}...`);
@@ -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,8 +58,7 @@ function startJob({
82
58
  jobs[jobId] = {
83
59
  status: JOB_STATUS.RUNNING,
84
60
  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.
61
+ // Both drained by pollJob — the primary has no socket back to this node.
87
62
  rrwebBatches: [],
88
63
  exitCode: null,
89
64
  startedAt: Date.now(),
@@ -112,23 +87,17 @@ function startJob({
112
87
 
113
88
  const ssPoller = startRRwebPoller(ssDir, (batch) => {
114
89
  jobs[jobId].rrwebBatches.push(batch);
115
- primaryStream?.emit('rrweb-batch', batch);
116
90
  });
117
91
 
118
92
  const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
119
93
  proc.stdout.on('data', (d) => {
120
- const text = d.toString();
121
- jobs[jobId].logs += text;
122
- primaryStream?.emit('log', text);
94
+ jobs[jobId].logs += d.toString();
123
95
  });
124
96
  proc.stderr.on('data', (d) => {
125
- const text = d.toString();
126
- jobs[jobId].logs += text;
127
- primaryStream?.emit('log', text);
97
+ jobs[jobId].logs += d.toString();
128
98
  });
129
99
  proc.on('close', (code) => {
130
100
  ssPoller.stop();
131
- primaryStream?.close();
132
101
  jobs[jobId].status = code === 0 ? JOB_STATUS.DONE : JOB_STATUS.ERROR;
133
102
  jobs[jobId].exitCode = code;
134
103
 
@@ -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,15 +244,11 @@ 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;
264
248
  let rrwebOffset = 0;
265
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.
266
252
  const poll = setInterval(async () => {
267
253
  if (polling) return;
268
254
  polling = true;
@@ -277,15 +263,12 @@ async function dispatchAndPoll(
277
263
  if (!res.ok) return;
278
264
  const body = await res.json();
279
265
 
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;
266
+ if (body.logs) {
267
+ onLog(body.logs);
268
+ logOffset += body.logs.length;
288
269
  }
270
+ for (const batch of body.rrwebBatches ?? []) onRRwebBatch?.(batch);
271
+ rrwebOffset += body.rrwebBatches?.length ?? 0;
289
272
 
290
273
  if (body.status === JOB_STATUS.DONE || body.status === JOB_STATUS.ERROR) {
291
274
  clearInterval(poll);
@@ -297,7 +280,7 @@ async function dispatchAndPoll(
297
280
  } finally {
298
281
  polling = false;
299
282
  }
300
- }, 2500);
283
+ }, 1000);
301
284
  }
302
285
 
303
286
  module.exports = {
package/bin/plum.js CHANGED
@@ -595,7 +595,8 @@ async function configureNode({ force }) {
595
595
  const saved = loadNodeConfig(cwd);
596
596
 
597
597
  let primary = getFlag(args, '--primary') ?? process.env.PRIMARY_URL ?? saved.primary ?? '';
598
- let port = getFlag(args, '--port') ?? saved.port ?? '3001';
598
+ // Not 3001 that's the primary's default; a co-located node must not collide.
599
+ let port = getFlag(args, '--port') ?? saved.port ?? '3002';
599
600
  let browser = getFlag(args, '--browser') ?? saved.browser ?? 'chromium';
600
601
  let token = getFlag(args, '--token') ?? process.env.NODE_TOKEN ?? saved.token ?? generateToken();
601
602
  let name = getFlag(args, '--name') ?? saved.name ?? `node-${token.slice(0, 6)}`;
@@ -613,6 +614,14 @@ async function configureNode({ force }) {
613
614
  const interactive = force || (interactiveAllowed() && !hasFlags);
614
615
 
615
616
  if (interactive) {
617
+ const nameVal = await clack.text({
618
+ message: 'Runner name',
619
+ placeholder: name,
620
+ defaultValue: name
621
+ });
622
+ if (clack.isCancel(nameVal)) cancelAndExit();
623
+ name = nameVal || name;
624
+
616
625
  const primaryVal = await clack.text({
617
626
  message: 'Your Plum server backend URL',
618
627
  placeholder: primary || 'http://localhost:3001',
@@ -732,7 +741,13 @@ async function nodeStart({ reconfig }) {
732
741
  const cfg = await configureNode({ force: reconfig });
733
742
  const registeredId = await registerNode(cfg);
734
743
 
735
- const { prepareEnv, startNode: startNodeProc } = runnerProcessLib();
744
+ const {
745
+ prepareEnv,
746
+ startNode: startNodeProc,
747
+ findPidOnPort,
748
+ killPort,
749
+ nodeReachable
750
+ } = runnerProcessLib();
736
751
 
737
752
  clack.log.step('Preparing environment (deps + browsers)...');
738
753
  try {
@@ -749,12 +764,30 @@ async function nodeStart({ reconfig }) {
749
764
 
750
765
  if (registeredId) {
751
766
  try {
767
+ // A stale process on this port makes the new node die on EADDRINUSE
768
+ // after a silent retry loop — clear it first (almost always a
769
+ // previous instance of this same node).
770
+ if (findPidOnPort(Number(cfg.port))) {
771
+ clack.log.step(`Port ${cfg.port} is in use — freeing it...`);
772
+ await killPort(Number(cfg.port));
773
+ }
752
774
  const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
753
- clack.log.success(
754
- pc.green(
755
- `Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at backend/logs/runner-${registeredId}.log`
756
- )
757
- );
775
+ clack.log.step(`Starting "${cfg.name}" (pid ${entry.pid})...`);
776
+ const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
777
+ if (up) {
778
+ clack.log.success(
779
+ pc.green(
780
+ `Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at ${entry.logFile}`
781
+ )
782
+ );
783
+ } else {
784
+ clack.log.error(
785
+ pc.red(
786
+ `Node "${cfg.name}" did not come up on port ${cfg.port}. Check ${entry.logFile} — the port may still be held by another process.`
787
+ )
788
+ );
789
+ process.exitCode = 1;
790
+ }
758
791
  } catch (e) {
759
792
  clack.log.warn(`Could not start runner process: ${e.message}`);
760
793
  }
@@ -769,7 +802,7 @@ async function nodeStart({ reconfig }) {
769
802
  async function nodeRestart() {
770
803
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Restart ')));
771
804
  const { loadNodeConfig } = nodeRegisterLib();
772
- const { prepareEnv, stopNode, startNode } = runnerProcessLib();
805
+ const { prepareEnv, stopNode, startNode, killPort, nodeReachable } = runnerProcessLib();
773
806
  const cfg = loadNodeConfig(process.cwd());
774
807
 
775
808
  if (!cfg.id) {
@@ -802,17 +835,27 @@ async function nodeRestart() {
802
835
  }
803
836
 
804
837
  try {
838
+ await killPort(Number(cfg.port));
805
839
  const entry = startNode({ id: String(cfg.id), port: cfg.port, token: cfg.token });
806
- clack.log.success(
807
- pc.green(
808
- `Node "${cfg.name}" restarted (pid ${entry.pid}) — logs at backend/logs/runner-${cfg.id}.log`
809
- )
810
- );
840
+ const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
841
+ if (up) {
842
+ clack.log.success(
843
+ pc.green(`Node "${cfg.name}" restarted (pid ${entry.pid}) — logs at ${entry.logFile}`)
844
+ );
845
+ clack.outro(pc.green('Node restarted.'));
846
+ } else {
847
+ clack.log.error(
848
+ pc.red(
849
+ `Node "${cfg.name}" did not come back up on port ${cfg.port}. Check ${entry.logFile}.`
850
+ )
851
+ );
852
+ process.exitCode = 1;
853
+ clack.outro(pc.red('Node not restarted.'));
854
+ }
811
855
  } catch (e) {
812
856
  clack.log.warn(`Could not restart node: ${e.message}`);
857
+ clack.outro(pc.red('Node not restarted.'));
813
858
  }
814
-
815
- clack.outro(pc.green('Node restarted.'));
816
859
  }
817
860
 
818
861
  async function nodeReconfig() {
@@ -823,13 +866,41 @@ async function nodeReconfig() {
823
866
  clack.outro(pc.dim('Done.'));
824
867
  }
825
868
 
869
+ // stop/restart/delete on the /runners API want a registered runner's token
870
+ // (runnerOrAdmin). On the primary host those tokens sit in the backend's DB —
871
+ // pull one straight from the running container so the menu can authenticate
872
+ // without a node's .plum-node.json in the current folder. Best-effort: on a
873
+ // node-only box (no server install / no Docker) this no-ops and the menu falls
874
+ // back to a local .plum-node.json.
875
+ function readRunnerTokenFromPrimary() {
876
+ const { getInstalls } = globalRegistryLib();
877
+ const script =
878
+ "require('./services/prisma').runner.findFirst({select:{token:true}})" +
879
+ ".then(r=>{process.stdout.write(r&&r.token||'');process.exit(0)}).catch(()=>process.exit(1))";
880
+ for (const dir of getInstalls('server')) {
881
+ try {
882
+ const token = execSync(`docker compose exec -T backend node -e "${script}"`, {
883
+ cwd: dir,
884
+ stdio: ['ignore', 'pipe', 'ignore'],
885
+ timeout: 15000
886
+ })
887
+ .toString()
888
+ .trim();
889
+ if (token) return token;
890
+ } catch {}
891
+ }
892
+ return null;
893
+ }
894
+
826
895
  async function openManageRunnersMenu(primaryUrl) {
827
896
  const manageScript = path.join(plumRoot, 'backend', 'scripts', 'manage-runners.mjs');
828
897
  const apiUrl = primaryUrl || 'http://localhost:3001';
829
- const menu = spawn(process.execPath, [manageScript], {
830
- stdio: 'inherit',
831
- env: { ...process.env, PLUM_API_URL: apiUrl }
832
- });
898
+ const env = { ...process.env, PLUM_API_URL: apiUrl };
899
+ if (!env.PLUM_RUNNER_TOKEN) {
900
+ const token = readRunnerTokenFromPrimary();
901
+ if (token) env.PLUM_RUNNER_TOKEN = token;
902
+ }
903
+ const menu = spawn(process.execPath, [manageScript], { stdio: 'inherit', env });
833
904
  await new Promise((resolve) => menu.on('exit', resolve));
834
905
  }
835
906
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.7",
3
+ "version": "2.9.9",
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;