plum-e2e 1.3.1 → 1.3.3

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.
@@ -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.2.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.2.1",
9
+ "version": "1.3.3",
10
10
  "license": "ISC",
11
11
  "dependencies": {
12
12
  "@clack/prompts": "^1.5.1",
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "plum-backend",
3
- "version": "1.3.1",
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
- "add-local-runner": "node scripts/add-local-runner.js",
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
- socketHandler(io);
46
- if (!isNodeMode) cronService.setSocketIO(io);
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 (!isNodeMode) await cronService.init();
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
- const key = feature.uri ?? feature.id ?? feature.name;
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
- onDone(1, null);
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
- onDone(1, null);
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
- onDone(body.exitCode ?? (body.status === 'done' ? 0 : 1), content);
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
  }
@@ -46,6 +46,18 @@ const socketHandler = (io) => {
46
46
  : [BUILT_IN_RUNNER_ID];
47
47
  }
48
48
 
49
+ // Drop runner ids that no longer exist (e.g. a deleted runner still
50
+ // referenced by a stale client selection) so they can't wedge the run.
51
+ const validatedRunners = [];
52
+ for (const id of runners) {
53
+ if (id === BUILT_IN_RUNNER_ID || (await runnerService.getById(id))) {
54
+ validatedRunners.push(id);
55
+ } else {
56
+ socket.emit('log', `[WARN] Runner ${id} no longer exists — skipping.\n`);
57
+ }
58
+ }
59
+ runners = validatedRunners.length > 0 ? validatedRunners : [BUILT_IN_RUNNER_ID];
60
+
49
61
  const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
50
62
 
51
63
  if (isSingleBuiltIn) {
@@ -132,7 +144,6 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
132
144
 
133
145
  if (doneCount === total) {
134
146
  socket.emit('log', `\nAll runners finished (exit ${overallCode})`);
135
- socket.emit('done', overallCode);
136
147
 
137
148
  reportService
138
149
  .saveCombinedReport({
@@ -143,8 +154,17 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
143
154
  triggerType: TRIGGER_TYPE.MANUAL,
144
155
  browser
145
156
  })
146
- .then(() => io.emit('report-ready'))
147
- .catch((e) => console.error('[runner] Failed to save combined report:', e.message));
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
+ });
148
168
  }
149
169
  }
150
170