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.
- package/backend/lib/rrwebPoller.js +7 -2
- package/backend/lib/serverBootstrap.js +0 -2
- package/backend/logs/runner-cmtcyrx2v0007o0017vy2cym0.log +6 -0
- package/backend/package-lock.json +1 -59
- package/backend/package.json +1 -2
- package/backend/routes/node.routes.js +2 -1
- package/backend/scripts/manage-runners.mjs +94 -24
- package/backend/services/cronService.js +2 -2
- package/backend/services/nodeExecutionService.js +13 -39
- package/backend/services/runnerService.js +15 -23
- 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
- package/backend/services/nodeStreamRegistry.js +0 -24
- package/backend/websockets/nodeSocketHandler.js +0 -40
|
@@ -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 };
|
|
@@ -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
|
|
|
@@ -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",
|
package/backend/package.json
CHANGED
|
@@ -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
|
});
|
|
@@ -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
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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) });
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
270
|
-
|
|
271
|
-
|
|
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 (
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
|
package/package.json
CHANGED
|
@@ -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;
|