plum-e2e 1.3.2 → 1.3.4
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/CLAUDE.md +1 -1
- package/README.md +100 -26
- package/backend/app.js +10 -13
- package/backend/config/scripts/run-tests.js +60 -10
- package/backend/lib/nodeRegister.js +101 -0
- package/backend/lib/runnerProcess.js +180 -0
- package/backend/lib/serverConfig.js +112 -0
- package/backend/package-lock.json +2 -2
- package/backend/package.json +2 -2
- package/backend/scripts/manage-runners.mjs +297 -0
- package/backend/server.js +8 -5
- package/backend/services/reportService.js +17 -1
- package/backend/services/runnerService.js +18 -3
- package/backend/websockets/socketHandler.js +11 -3
- package/bin/plum.js +394 -102
- package/bin/scaffold-tests.js +34 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/Button.cBruH0aD.css +17 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.BVnUajEa.css +17 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
- package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/0.CnXRuPt4.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/2.CGnCsn5q.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/3.BVnUajEa.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/4.DBhBrHFz.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/5.D93VAB-w.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/Button.cBruH0aD.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.BVnUajEa.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
- package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/Button.cBruH0aD.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.BVnUajEa.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
- package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
- package/frontend/build/client/_app/immutable/assets/0.CnXRuPt4.css +17 -0
- package/frontend/build/client/_app/immutable/assets/2.CGnCsn5q.css +17 -0
- package/frontend/build/client/_app/immutable/assets/3.BVnUajEa.css +17 -0
- package/frontend/build/client/_app/immutable/assets/4.DBhBrHFz.css +17 -0
- package/frontend/build/client/_app/immutable/assets/5.D93VAB-w.css +17 -0
- package/frontend/build/client/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
- package/frontend/build/client/_app/immutable/assets/Button.cBruH0aD.css +17 -0
- package/frontend/build/client/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
- package/frontend/build/client/_app/immutable/assets/_page.BVnUajEa.css +17 -0
- package/frontend/build/client/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
- package/frontend/build/client/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
- package/frontend/build/client/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
- package/frontend/package-lock.json +6 -1357
- package/frontend/package.json +1 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +22 -16
- package/frontend/src/lib/utils/format.js +13 -0
- package/frontend/src/routes/reports/[id]/+page.svelte +2 -30
- package/frontend/src/routes/reports/live/+page.svelte +2 -2
- package/package.json +2 -2
- package/backend/scripts/add-local-runner.js +0 -120
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
*
|
|
4
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU General Public License as published by
|
|
6
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* Plum is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU General Public License
|
|
15
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Source-of-truth config for the `plum server` flow. `.env` and the generated
|
|
20
|
+
* docker-compose override are derived from this file, so reconfiguring URLs and
|
|
21
|
+
* ports happens in one place.
|
|
22
|
+
*
|
|
23
|
+
* Uses only Node builtins so it can be imported from the published `bin/plum.js`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { detectLanIp } = require('./nodeRegister');
|
|
29
|
+
|
|
30
|
+
const CONFIG_FILENAME = '.plum-server.json';
|
|
31
|
+
|
|
32
|
+
function defaults() {
|
|
33
|
+
const backendPort = '3001';
|
|
34
|
+
return {
|
|
35
|
+
baseUrl: 'https://www.saucedemo.com/v1/',
|
|
36
|
+
headless: false,
|
|
37
|
+
backendPort,
|
|
38
|
+
frontendPort: '5173',
|
|
39
|
+
primaryPublicUrl: `http://${detectLanIp()}:${backendPort}`
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function configPath(dir) {
|
|
44
|
+
return path.join(dir, CONFIG_FILENAME);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Seeds baseUrl/headless from an existing .env so first-run prompts reflect it. */
|
|
48
|
+
function readEnvSeed(dir) {
|
|
49
|
+
try {
|
|
50
|
+
const txt = fs.readFileSync(path.join(dir, '.env'), 'utf8');
|
|
51
|
+
const seed = {};
|
|
52
|
+
const baseUrl = txt.match(/^BASE_URL=(.*)$/m);
|
|
53
|
+
if (baseUrl) seed.baseUrl = baseUrl[1].trim();
|
|
54
|
+
const headless = txt.match(/^IS_HEADLESS=(.*)$/m);
|
|
55
|
+
if (headless) seed.headless = headless[1].trim() === 'true';
|
|
56
|
+
return seed;
|
|
57
|
+
} catch {
|
|
58
|
+
return {};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function loadServerConfig(dir) {
|
|
63
|
+
const base = { ...defaults(), ...readEnvSeed(dir) };
|
|
64
|
+
try {
|
|
65
|
+
return { ...base, ...JSON.parse(fs.readFileSync(configPath(dir), 'utf8')) };
|
|
66
|
+
} catch {
|
|
67
|
+
return base;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function saveServerConfig(dir, cfg) {
|
|
72
|
+
fs.writeFileSync(configPath(dir), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Writes the root .env consumed by the backend/tests from the server config. */
|
|
76
|
+
function writeEnvFile(dir, { baseUrl, headless }) {
|
|
77
|
+
const content = `BASE_URL=${baseUrl}\nIS_HEADLESS=${headless ? 'true' : 'false'}\n`;
|
|
78
|
+
fs.writeFileSync(path.join(dir, '.env'), content, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Builds docker-compose.override.yml. Containers keep their internal ports
|
|
83
|
+
* (3001/5173); only the host side is remapped. The frontend is told where to
|
|
84
|
+
* reach the backend via VITE_API_URL (read by Vite at dev runtime).
|
|
85
|
+
*/
|
|
86
|
+
function buildOverrideYaml({ testsAbs, reportsAbs, backendPort, frontendPort }) {
|
|
87
|
+
return (
|
|
88
|
+
[
|
|
89
|
+
'services:',
|
|
90
|
+
' backend:',
|
|
91
|
+
' ports:',
|
|
92
|
+
` - "${backendPort}:3001"`,
|
|
93
|
+
' volumes:',
|
|
94
|
+
` - "${reportsAbs}:/app/reports"`,
|
|
95
|
+
` - "${testsAbs}:/app/tests"`,
|
|
96
|
+
' frontend:',
|
|
97
|
+
' ports:',
|
|
98
|
+
` - "${frontendPort}:5173"`,
|
|
99
|
+
' environment:',
|
|
100
|
+
` VITE_API_URL: "http://localhost:${backendPort}"`
|
|
101
|
+
].join('\n') + '\n'
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
CONFIG_FILENAME,
|
|
107
|
+
defaults,
|
|
108
|
+
loadServerConfig,
|
|
109
|
+
saveServerConfig,
|
|
110
|
+
writeEnvFile,
|
|
111
|
+
buildOverrideYaml
|
|
112
|
+
};
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plum-backend",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "plum-backend",
|
|
9
|
-
"version": "1.
|
|
9
|
+
"version": "1.3.3",
|
|
10
10
|
"license": "ISC",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@clack/prompts": "^1.5.1",
|
package/backend/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plum-backend",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.3",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"init": "node services/envService.js",
|
|
7
7
|
"create-step": "node config/scripts/create-step.mjs",
|
|
8
8
|
"create-env": "node services/envService.js",
|
|
9
9
|
"test": "node config/scripts/run-tests.js",
|
|
10
|
-
"
|
|
10
|
+
"manage-runners": "node scripts/manage-runners.mjs",
|
|
11
11
|
"create-test": "node scripts/create-test.js"
|
|
12
12
|
},
|
|
13
13
|
"keywords": [],
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* This file is part of Plum.
|
|
3
|
+
*
|
|
4
|
+
* Plum is free software: you can redistribute it and/or modify
|
|
5
|
+
* it under the terms of the GNU General Public License as published by
|
|
6
|
+
* the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
* (at your option) any later version.
|
|
8
|
+
*
|
|
9
|
+
* Plum is distributed in the hope that it will be useful,
|
|
10
|
+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
* GNU General Public License for more details.
|
|
13
|
+
*
|
|
14
|
+
* You should have received a copy of the GNU General Public License
|
|
15
|
+
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Interactive manager for local node runners.
|
|
20
|
+
*
|
|
21
|
+
* Registers new runners with the Plum primary and starts / stops / restarts the
|
|
22
|
+
* node processes that back local runners, all from one menu.
|
|
23
|
+
*
|
|
24
|
+
* Usage: node scripts/manage-runners.mjs
|
|
25
|
+
* or: npm run manage-runners (from the backend directory)
|
|
26
|
+
*
|
|
27
|
+
* Env: PLUM_API_URL primary server API base (default http://localhost:3001)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as clack from '@clack/prompts';
|
|
31
|
+
import pc from 'picocolors';
|
|
32
|
+
import runnerProcess from '../lib/runnerProcess.js';
|
|
33
|
+
import nodeRegister from '../lib/nodeRegister.js';
|
|
34
|
+
|
|
35
|
+
const { isLocalUrl, parsePort, pruneDead, statusOf, prepareEnv, startNode, stopNode } =
|
|
36
|
+
runnerProcess;
|
|
37
|
+
const { generateToken, registerWithPrimary } = nodeRegister;
|
|
38
|
+
|
|
39
|
+
const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
|
|
40
|
+
|
|
41
|
+
const cancelled = (v) => clack.isCancel(v);
|
|
42
|
+
|
|
43
|
+
async function fetchRunners() {
|
|
44
|
+
const res = await fetch(`${API_URL}/runners`);
|
|
45
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
46
|
+
const body = await res.json();
|
|
47
|
+
return body.runners ?? [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function pingRunner(id) {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(`${API_URL}/runners/${id}/ping`, { method: 'POST' });
|
|
53
|
+
const body = await res.json().catch(() => ({}));
|
|
54
|
+
return body.ok === true;
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function deleteRunner(id) {
|
|
61
|
+
const res = await fetch(`${API_URL}/runners/${id}`, { method: 'DELETE' });
|
|
62
|
+
if (!res.ok) {
|
|
63
|
+
const body = await res.json().catch(() => ({}));
|
|
64
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolves the display + control state for every runner: reachability (ping),
|
|
70
|
+
* whether we own a live process for it, and whether we can control it at all.
|
|
71
|
+
*/
|
|
72
|
+
async function describeRunners() {
|
|
73
|
+
const runners = await fetchRunners();
|
|
74
|
+
pruneDead();
|
|
75
|
+
|
|
76
|
+
return Promise.all(
|
|
77
|
+
runners.map(async (r) => {
|
|
78
|
+
const online = await pingRunner(r.id);
|
|
79
|
+
const local = isLocalUrl(r.url);
|
|
80
|
+
const managed = statusOf(r.id) === 'running';
|
|
81
|
+
let state;
|
|
82
|
+
if (managed) state = 'managed';
|
|
83
|
+
else if (online) state = 'unmanaged';
|
|
84
|
+
else state = 'stopped';
|
|
85
|
+
return { ...r, online, local, managed, state };
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function statusBadge(r) {
|
|
91
|
+
const dot = r.online ? pc.green('●') : pc.dim('○');
|
|
92
|
+
let detail;
|
|
93
|
+
if (!r.local) detail = pc.dim('remote');
|
|
94
|
+
else if (r.managed) detail = pc.green('running');
|
|
95
|
+
else if (r.online) detail = pc.yellow('running (unmanaged)');
|
|
96
|
+
else detail = pc.dim('stopped');
|
|
97
|
+
return `${dot} ${detail}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Installs backend deps + the Playwright browser so a freshly started node can
|
|
102
|
+
* actually launch a browser. Runs with inherited stdio (outside any spinner) so
|
|
103
|
+
* npm/playwright progress is visible. A failure is surfaced but non-fatal — the
|
|
104
|
+
* operator can retry or fix it manually.
|
|
105
|
+
*/
|
|
106
|
+
function prepareNodeEnv(browser) {
|
|
107
|
+
clack.log.step(`Preparing node environment (deps + ${browser ?? 'browser'})...`);
|
|
108
|
+
try {
|
|
109
|
+
prepareEnv(browser);
|
|
110
|
+
clack.log.success(pc.green('Environment ready.'));
|
|
111
|
+
return true;
|
|
112
|
+
} catch (e) {
|
|
113
|
+
clack.log.warn(pc.yellow(`Environment prep failed: ${e.message}`));
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function runAction(r) {
|
|
119
|
+
const options = [];
|
|
120
|
+
|
|
121
|
+
if (!r.local) {
|
|
122
|
+
clack.log.info(
|
|
123
|
+
pc.dim(`"${r.name}" runs on another machine — it can be pinged but not controlled here.`)
|
|
124
|
+
);
|
|
125
|
+
options.push({ value: 'ping', label: 'Ping' });
|
|
126
|
+
} else if (r.managed) {
|
|
127
|
+
options.push(
|
|
128
|
+
{ value: 'stop', label: pc.red('Stop') },
|
|
129
|
+
{ value: 'restart', label: pc.yellow('Restart') },
|
|
130
|
+
{ value: 'log', label: 'Show log path' },
|
|
131
|
+
{ value: 'ping', label: 'Ping' }
|
|
132
|
+
);
|
|
133
|
+
} else if (r.online) {
|
|
134
|
+
clack.log.info(
|
|
135
|
+
pc.dim(
|
|
136
|
+
`"${r.name}" is up but was not started by this manager — stop it from its own terminal.`
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
options.push({ value: 'ping', label: 'Ping' });
|
|
140
|
+
} else {
|
|
141
|
+
options.push({ value: 'start', label: pc.green('Start') }, { value: 'ping', label: 'Ping' });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
options.push(
|
|
145
|
+
{ value: 'delete', label: pc.red('Delete') },
|
|
146
|
+
{ value: 'back', label: pc.dim('← Back') }
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const action = await clack.select({ message: `${r.name} — ${r.url}`, options });
|
|
150
|
+
if (cancelled(action) || action === 'back') return;
|
|
151
|
+
|
|
152
|
+
const port = parsePort(r.url);
|
|
153
|
+
|
|
154
|
+
if (action === 'start') {
|
|
155
|
+
prepareNodeEnv(r.browser);
|
|
156
|
+
const entry = startNode({ id: r.id, port, token: r.token });
|
|
157
|
+
clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
|
|
158
|
+
} else if (action === 'stop') {
|
|
159
|
+
const ok = stopNode(r.id);
|
|
160
|
+
clack.log.success(ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`));
|
|
161
|
+
} else if (action === 'restart') {
|
|
162
|
+
const s = clack.spinner();
|
|
163
|
+
s.start(`Restarting "${r.name}"...`);
|
|
164
|
+
stopNode(r.id);
|
|
165
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
166
|
+
const entry = startNode({ id: r.id, port, token: r.token });
|
|
167
|
+
s.stop(pc.green(`Restarted "${r.name}" (pid ${entry.pid})`));
|
|
168
|
+
} else if (action === 'log') {
|
|
169
|
+
const entry = runnerProcess.loadRegistry()[r.id];
|
|
170
|
+
clack.note(entry?.logFile ?? '(no log file)', 'Log file');
|
|
171
|
+
} else if (action === 'ping') {
|
|
172
|
+
const s = clack.spinner();
|
|
173
|
+
s.start(`Pinging "${r.name}"...`);
|
|
174
|
+
const online = await pingRunner(r.id);
|
|
175
|
+
s.stop(online ? pc.green(`"${r.name}" is reachable`) : pc.red(`"${r.name}" is unreachable`));
|
|
176
|
+
} else if (action === 'delete') {
|
|
177
|
+
const confirmed = await clack.confirm({
|
|
178
|
+
message: `Delete runner "${r.name}"? This removes it from the server.`,
|
|
179
|
+
initialValue: false
|
|
180
|
+
});
|
|
181
|
+
if (cancelled(confirmed) || !confirmed) return;
|
|
182
|
+
const s = clack.spinner();
|
|
183
|
+
s.start(`Deleting "${r.name}"...`);
|
|
184
|
+
if (r.managed) stopNode(r.id);
|
|
185
|
+
try {
|
|
186
|
+
await deleteRunner(r.id);
|
|
187
|
+
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
188
|
+
} catch (e) {
|
|
189
|
+
s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function addRunner() {
|
|
195
|
+
const suggested = `node-${generateToken().slice(0, 6)}`;
|
|
196
|
+
|
|
197
|
+
const name = await clack.text({ message: 'Runner name', placeholder: suggested, defaultValue: suggested });
|
|
198
|
+
if (cancelled(name)) return;
|
|
199
|
+
|
|
200
|
+
const port = await clack.text({
|
|
201
|
+
message: 'Local port the node listens on',
|
|
202
|
+
placeholder: '3002',
|
|
203
|
+
defaultValue: '3002'
|
|
204
|
+
});
|
|
205
|
+
if (cancelled(port)) return;
|
|
206
|
+
|
|
207
|
+
const browser = await clack.select({
|
|
208
|
+
message: 'Default browser',
|
|
209
|
+
options: [
|
|
210
|
+
{ value: 'chromium', label: 'Chrome' },
|
|
211
|
+
{ value: 'firefox', label: 'Firefox' },
|
|
212
|
+
{ value: 'webkit', label: 'WebKit' }
|
|
213
|
+
],
|
|
214
|
+
initialValue: 'chromium'
|
|
215
|
+
});
|
|
216
|
+
if (cancelled(browser)) return;
|
|
217
|
+
|
|
218
|
+
const defToken = process.env.NODE_TOKEN || generateToken();
|
|
219
|
+
const token = await clack.text({ message: 'Auth token', placeholder: defToken, defaultValue: defToken });
|
|
220
|
+
if (cancelled(token)) return;
|
|
221
|
+
|
|
222
|
+
// Dev nodes run as a bare process on the host; the dockerized primary reaches
|
|
223
|
+
// them via host.docker.internal.
|
|
224
|
+
const url = `http://host.docker.internal:${port}`;
|
|
225
|
+
|
|
226
|
+
const s = clack.spinner();
|
|
227
|
+
s.start(`Registering "${name}" with the primary...`);
|
|
228
|
+
let id;
|
|
229
|
+
try {
|
|
230
|
+
const res = await registerWithPrimary({ primary: API_URL, name, url, token, browser });
|
|
231
|
+
id = res.id;
|
|
232
|
+
s.stop(res.reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}" (id ${id})`));
|
|
233
|
+
} catch (e) {
|
|
234
|
+
s.stop(pc.red(`Could not register "${name}": ${e.message}`));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
prepareNodeEnv(browser);
|
|
239
|
+
|
|
240
|
+
const startNow = await clack.confirm({ message: 'Start this runner now?' });
|
|
241
|
+
if (!cancelled(startNow) && startNow) {
|
|
242
|
+
const entry = startNode({ id, port, token });
|
|
243
|
+
clack.log.success(pc.green(`Started "${name}" on port ${port} (pid ${entry.pid})`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function main() {
|
|
248
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Manage Runners ')));
|
|
249
|
+
|
|
250
|
+
for (;;) {
|
|
251
|
+
const s = clack.spinner();
|
|
252
|
+
s.start(`Loading runners from ${API_URL}...`);
|
|
253
|
+
let runners;
|
|
254
|
+
try {
|
|
255
|
+
runners = await describeRunners();
|
|
256
|
+
s.stop(`Runners at ${API_URL}`);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
s.stop(pc.red(`Could not reach Plum server at ${API_URL}`));
|
|
259
|
+
clack.log.error(e.message);
|
|
260
|
+
clack.outro(pc.dim('Is the primary server running? (docker compose up -d)'));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (runners.length === 0) clack.log.info(pc.dim('No runners registered yet.'));
|
|
265
|
+
|
|
266
|
+
const choice = await clack.select({
|
|
267
|
+
message: runners.length ? 'Select a runner' : 'No runners yet',
|
|
268
|
+
options: [
|
|
269
|
+
...runners.map((r) => ({
|
|
270
|
+
value: r.id,
|
|
271
|
+
label: r.name,
|
|
272
|
+
hint: statusBadge(r)
|
|
273
|
+
})),
|
|
274
|
+
{ value: '__add__', label: pc.green('+ Add new runner') },
|
|
275
|
+
{ value: '__refresh__', label: pc.cyan('↻ Refresh') },
|
|
276
|
+
{ value: '__quit__', label: pc.dim('Quit') }
|
|
277
|
+
]
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
if (cancelled(choice) || choice === '__quit__') break;
|
|
281
|
+
if (choice === '__refresh__') continue;
|
|
282
|
+
if (choice === '__add__') {
|
|
283
|
+
await addRunner();
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const runner = runners.find((r) => r.id === choice);
|
|
288
|
+
if (runner) await runAction(runner);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
clack.outro(pc.magenta('Done.'));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
main().catch((err) => {
|
|
295
|
+
clack.log.error(err.message);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
});
|
package/backend/server.js
CHANGED
|
@@ -18,8 +18,6 @@
|
|
|
18
18
|
const http = require('http');
|
|
19
19
|
const { Server } = require('socket.io');
|
|
20
20
|
const app = require('./app');
|
|
21
|
-
const socketHandler = require('./websockets/socketHandler.js');
|
|
22
|
-
const cronService = require('./services/cronService');
|
|
23
21
|
const server = http.createServer(app);
|
|
24
22
|
const io = new Server(server, { cors: { origin: '*' } });
|
|
25
23
|
const path = require('path');
|
|
@@ -42,11 +40,16 @@ if (!fs.existsSync(testsDir)) {
|
|
|
42
40
|
const isNodeMode = process.env.PLUM_MODE === 'node';
|
|
43
41
|
const port = parseInt(process.env.PORT || '3001', 10);
|
|
44
42
|
|
|
45
|
-
|
|
46
|
-
if (!isNodeMode)
|
|
43
|
+
let cronService = null;
|
|
44
|
+
if (!isNodeMode) {
|
|
45
|
+
const socketHandler = require('./websockets/socketHandler.js');
|
|
46
|
+
cronService = require('./services/cronService');
|
|
47
|
+
socketHandler(io);
|
|
48
|
+
cronService.setSocketIO(io);
|
|
49
|
+
}
|
|
47
50
|
|
|
48
51
|
async function start() {
|
|
49
|
-
if (
|
|
52
|
+
if (cronService) await cronService.init();
|
|
50
53
|
|
|
51
54
|
server.listen(port, async () => {
|
|
52
55
|
console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
|
|
@@ -48,6 +48,18 @@ function collectScreenshotFiles(content) {
|
|
|
48
48
|
return files;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Stable identity for a Cucumber feature across distributed lanes. Dispatched
|
|
53
|
+
* runs report an absolute temp uri (…/plum-job-<uuid>/features/Login.feature)
|
|
54
|
+
* that differs per runner; the suffix from `features/` onward is stable.
|
|
55
|
+
*/
|
|
56
|
+
function featureMergeKey(feature) {
|
|
57
|
+
const uri = (feature.uri ?? '').replace(/\\/g, '/');
|
|
58
|
+
const idx = uri.lastIndexOf('/features/');
|
|
59
|
+
if (idx !== -1) return uri.slice(idx + 1);
|
|
60
|
+
return uri || feature.id || feature.name;
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
function deleteScreenshotFiles(content) {
|
|
52
64
|
for (const file of collectScreenshotFiles(content)) {
|
|
53
65
|
const p = path.join(SCREENSHOTS_DIR, file);
|
|
@@ -249,7 +261,11 @@ const saveCombinedReport = async ({ reports, runners, overallCode, tag, triggerT
|
|
|
249
261
|
continue;
|
|
250
262
|
}
|
|
251
263
|
for (const feature of parsed) {
|
|
252
|
-
|
|
264
|
+
// Each lane runs from its own temp dir, so the same feature reports a
|
|
265
|
+
// different absolute uri per runner. Key on the path from `features/`
|
|
266
|
+
// onward (falling back to name) so one feature's scenarios from every
|
|
267
|
+
// lane merge into a single entry instead of one duplicate per runner.
|
|
268
|
+
const key = featureMergeKey(feature);
|
|
253
269
|
if (featureMap.has(key)) {
|
|
254
270
|
featureMap.get(key).elements.push(...(feature.elements ?? []));
|
|
255
271
|
} else {
|
|
@@ -113,10 +113,20 @@ async function fetchReportContent(runner, jobId, onLog) {
|
|
|
113
113
|
* @param {(exitCode: number, reportContent: string|null) => void} onDone
|
|
114
114
|
*/
|
|
115
115
|
async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDone) {
|
|
116
|
+
// The async poll callback can overlap if a tick takes longer than the interval;
|
|
117
|
+
// guard so the run resolves exactly once and can't be finalised while a lane
|
|
118
|
+
// is still in flight.
|
|
119
|
+
let settled = false;
|
|
120
|
+
const finish = (code, content) => {
|
|
121
|
+
if (settled) return;
|
|
122
|
+
settled = true;
|
|
123
|
+
onDone(code, content);
|
|
124
|
+
};
|
|
125
|
+
|
|
116
126
|
const runner = await getById(runnerId);
|
|
117
127
|
if (!runner) {
|
|
118
128
|
onLog(`[ERROR] Runner ${runnerId} not found\n`);
|
|
119
|
-
|
|
129
|
+
finish(1, null);
|
|
120
130
|
return;
|
|
121
131
|
}
|
|
122
132
|
|
|
@@ -135,14 +145,17 @@ async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDo
|
|
|
135
145
|
jobId = (await res.json()).jobId;
|
|
136
146
|
} catch (e) {
|
|
137
147
|
onLog(`[ERROR] Could not reach runner "${runner.name}": ${e.message}\n`);
|
|
138
|
-
|
|
148
|
+
finish(1, null);
|
|
139
149
|
return;
|
|
140
150
|
}
|
|
141
151
|
|
|
142
152
|
onLog(`Connected to runner "${runner.name}" — job ${jobId}\n`);
|
|
143
153
|
|
|
144
154
|
let logOffset = 0;
|
|
155
|
+
let polling = false;
|
|
145
156
|
const poll = setInterval(async () => {
|
|
157
|
+
if (polling) return;
|
|
158
|
+
polling = true;
|
|
146
159
|
try {
|
|
147
160
|
const res = await fetch(`${runner.url}/api/execute/${jobId}?offset=${logOffset}`, {
|
|
148
161
|
headers: { Authorization: `Bearer ${runner.token}` },
|
|
@@ -159,10 +172,12 @@ async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDo
|
|
|
159
172
|
if (body.status === 'done' || body.status === 'error') {
|
|
160
173
|
clearInterval(poll);
|
|
161
174
|
const content = await fetchReportContent(runner, jobId, onLog);
|
|
162
|
-
|
|
175
|
+
finish(body.exitCode ?? (body.status === 'done' ? 0 : 1), content);
|
|
163
176
|
}
|
|
164
177
|
} catch {
|
|
165
178
|
// transient polling error — keep trying
|
|
179
|
+
} finally {
|
|
180
|
+
polling = false;
|
|
166
181
|
}
|
|
167
182
|
}, 2500);
|
|
168
183
|
}
|
|
@@ -144,7 +144,6 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
144
144
|
|
|
145
145
|
if (doneCount === total) {
|
|
146
146
|
socket.emit('log', `\nAll runners finished (exit ${overallCode})`);
|
|
147
|
-
socket.emit('done', overallCode);
|
|
148
147
|
|
|
149
148
|
reportService
|
|
150
149
|
.saveCombinedReport({
|
|
@@ -155,8 +154,17 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
155
154
|
triggerType: TRIGGER_TYPE.MANUAL,
|
|
156
155
|
browser
|
|
157
156
|
})
|
|
158
|
-
.then(() =>
|
|
159
|
-
|
|
157
|
+
.then((saved) => {
|
|
158
|
+
// Result is authoritative from the merged report, not the exit code —
|
|
159
|
+
// a node's non-test failure (e.g. a failed report fetch) must not flip
|
|
160
|
+
// a passing run to "fail" in the live UI.
|
|
161
|
+
socket.emit('done', { code: saved.status === 'PASS' ? 0 : 1, reportId: saved.id });
|
|
162
|
+
io.emit('report-ready');
|
|
163
|
+
})
|
|
164
|
+
.catch((e) => {
|
|
165
|
+
console.error('[runner] Failed to save combined report:', e.message);
|
|
166
|
+
socket.emit('done', { code: overallCode, reportId: null });
|
|
167
|
+
});
|
|
160
168
|
}
|
|
161
169
|
}
|
|
162
170
|
|