plum-e2e 2.6.0 → 2.6.2

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/app.js CHANGED
@@ -22,7 +22,10 @@ const { SCREENSHOTS_DIR } = require('./lib/reportFilename');
22
22
  const app = express();
23
23
 
24
24
  app.use(cors({ origin: '*' }));
25
- app.use(express.json());
25
+ // Dispatching a run to a node ships the whole tests/ tree (base64-encoded,
26
+ // fixtures included) as one JSON body — Express's 100kb default 413s well
27
+ // before a real test suite does.
28
+ app.use(express.json({ limit: '500mb' }));
26
29
 
27
30
  // Serve screenshot files written during report processing
28
31
  app.use('/screenshots', express.static(SCREENSHOTS_DIR));
@@ -0,0 +1,61 @@
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
+ const reportService = require('../services/reportService');
19
+
20
+ /**
21
+ * Runs a suite attempt via the caller-supplied `spawnAttempt`, re-running only
22
+ * the scenarios that failed (up to `maxRetries` extra rounds) until either
23
+ * everything passes or retries are exhausted. Agnostic to how an attempt is
24
+ * actually executed — the same loop drives both a local `npm run test` spawn
25
+ * and a remote `dispatchAndPoll` call, since both reduce to
26
+ * `(tagOverride, round) => Promise<{ code, rawJson }>`.
27
+ *
28
+ * @param {{
29
+ * maxRetries: number,
30
+ * spawnAttempt: (tagOverride: string|null, round: number) => Promise<{ code: number, rawJson: object[] }>,
31
+ * onLog: (text: string) => void
32
+ * }} opts
33
+ * @returns {Promise<{ code: number, rawJson: object[], attempts: Record<string, number> }>}
34
+ */
35
+ async function runWithRetries({ maxRetries, spawnAttempt, onLog }) {
36
+ const accumulated = [];
37
+ const attempts = {};
38
+ let round = 1;
39
+ let tagOverride = null;
40
+ let code = 0;
41
+
42
+ // eslint-disable-next-line no-constant-condition
43
+ while (true) {
44
+ const result = await spawnAttempt(tagOverride, round);
45
+ code = result.code;
46
+ reportService.mergeRawAttempt(accumulated, result.rawJson, round, attempts);
47
+
48
+ const failedIds = reportService.getFailedIdTags(result.rawJson);
49
+ if (failedIds.length === 0 || round > maxRetries) break;
50
+
51
+ onLog(
52
+ `\n[RETRY] ${failedIds.length} test(s) failed — retrying (attempt ${round + 1}/${maxRetries + 1})\n`
53
+ );
54
+ tagOverride = failedIds.join(' or ');
55
+ round++;
56
+ }
57
+
58
+ return { code, rawJson: accumulated, attempts };
59
+ }
60
+
61
+ module.exports = { runWithRetries };
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Project" ADD COLUMN "maxRetries" INTEGER NOT NULL DEFAULT 0;
@@ -102,6 +102,7 @@ model Project {
102
102
  backupLastRunAt DateTime?
103
103
  backupLastStatus String @default("")
104
104
  mcpKey String @default("")
105
+ maxRetries Int @default(0)
105
106
  }
106
107
 
107
108
  model User {
@@ -45,6 +45,26 @@ router.post('/shutdown', authGuard, (req, res) => {
45
45
  setTimeout(() => process.exit(0), 200);
46
46
  });
47
47
 
48
+ // Self-restart for node-mode processes: spawns a replacement using this
49
+ // process's own env (RUNNER_ID/PORT/NODE_TOKEN), then exits. The replacement
50
+ // binds the same port, retrying past the brief EADDRINUSE window left by this
51
+ // process shutting down (see the listen retry in server.js).
52
+ router.post('/restart', authGuard, (req, res) => {
53
+ if (process.env.PLUM_MODE !== 'node') {
54
+ return res.status(403).json({ error: 'Not a node runner' });
55
+ }
56
+ const id = process.env.RUNNER_ID;
57
+ if (!id) {
58
+ return res.status(400).json({ error: 'Runner has no RUNNER_ID — cannot self-restart' });
59
+ }
60
+ res.json({ ok: true });
61
+ setTimeout(() => {
62
+ const { startNode } = require('../lib/runnerProcess');
63
+ startNode({ id, port: process.env.PORT || '3001', token: process.env.NODE_TOKEN });
64
+ process.exit(0);
65
+ }, 200);
66
+ });
67
+
48
68
  // Start a remote test job
49
69
  router.post('/execute', authGuard, (req, res) => {
50
70
  const { tags, browser = 'chromium', workers = 1, tests = null, env: userEnv = {} } = req.body;
@@ -64,16 +64,7 @@ router.put('/:id', async (req, res) => {
64
64
 
65
65
  router.delete('/:id', async (req, res) => {
66
66
  try {
67
- const runner = await runnerService.getById(req.params.id);
68
- if (runner) {
69
- try {
70
- await fetch(`${runner.url}/api/shutdown`, {
71
- method: 'POST',
72
- headers: { Authorization: `Bearer ${runner.token}` },
73
- signal: AbortSignal.timeout(3000)
74
- });
75
- } catch {}
76
- }
67
+ await runnerService.stop(req.params.id);
77
68
  await runnerService.remove(req.params.id);
78
69
  res.json({ message: 'Runner deleted' });
79
70
  } catch (e) {
@@ -90,4 +81,22 @@ router.post('/:id/ping', async (req, res) => {
90
81
  }
91
82
  });
92
83
 
84
+ router.post('/:id/stop', async (req, res) => {
85
+ try {
86
+ const result = await runnerService.stop(req.params.id);
87
+ res.json(result);
88
+ } catch (e) {
89
+ res.status(500).json({ ok: false, error: e.message });
90
+ }
91
+ });
92
+
93
+ router.post('/:id/restart', async (req, res) => {
94
+ try {
95
+ const result = await runnerService.restart(req.params.id);
96
+ res.json(result);
97
+ } catch (e) {
98
+ res.status(500).json({ ok: false, error: e.message });
99
+ }
100
+ });
101
+
93
102
  module.exports = router;
@@ -33,8 +33,8 @@ router.get('/project', async (req, res, next) => {
33
33
 
34
34
  router.post('/project', async (req, res, next) => {
35
35
  try {
36
- const { name, logoUrl, timezone } = req.body;
37
- const project = await settingsService.updateProject({ name, logoUrl, timezone });
36
+ const { name, logoUrl, timezone, maxRetries } = req.body;
37
+ const project = await settingsService.updateProject({ name, logoUrl, timezone, maxRetries });
38
38
  res.json(project);
39
39
  } catch (e) {
40
40
  next(e);
@@ -26,6 +26,10 @@ const { jwtAuth } = require('../middleware/jwtAuth');
26
26
  const prisma = require('../services/prisma');
27
27
  const { startSsPoller } = require('../lib/screenshotPoller');
28
28
  const { TRIGGER_TYPE } = require('../constants/triggers');
29
+ const settingsService = require('../services/settingsService');
30
+ const reportService = require('../services/reportService');
31
+ const { readCucumberReportFile } = require('../lib/reportFilename');
32
+ const { runWithRetries } = require('../lib/retryRunner');
29
33
 
30
34
  const BACKEND_DIR = path.resolve(__dirname, '..');
31
35
  const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -51,28 +55,12 @@ router.post('/', jwtAuth, async (req, res, next) => {
51
55
 
52
56
  const { tag = '', browser = 'chromium', workers = 1, baseUrl, testRunId, source } = req.body;
53
57
  const trigger = source === 'mcp' ? TRIGGER_TYPE.MCP : TRIGGER_TYPE.EXTERNAL;
58
+ const { maxRetries } = await settingsService.getProject();
54
59
 
55
60
  const jobId = randomUUID();
56
61
  const startedAt = Date.now();
57
62
  jobs.set(jobId, { status: 'running', exitCode: null, reportId: null, startedAt });
58
63
 
59
- const ssDir = path.join(os.tmpdir(), `plum-trigger-ss-${jobId}`);
60
- fs.mkdirSync(ssDir, { recursive: true });
61
-
62
- const env = {
63
- ...process.env,
64
- TAG: tag,
65
- TRIGGER: trigger,
66
- BROWSER: browser,
67
- REPORT_RUNNERS: String(workers),
68
- PLUM_SS_DIR: ssDir
69
- };
70
- if (Number(workers) > 1) env.PARALLEL = String(workers);
71
- if (testRunId) env.TEST_RUN_ID = testRunId;
72
- if (baseUrl) env.BASE_URL = baseUrl;
73
-
74
- const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
75
-
76
64
  if (_io) {
77
65
  _io.emit('bg-run-start', {
78
66
  runId: jobId,
@@ -82,48 +70,108 @@ router.post('/', jwtAuth, async (req, res, next) => {
82
70
  });
83
71
  }
84
72
 
85
- const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
86
- if (_io) _io.emit('bg-run-screenshot', { runId: jobId, stepName, data });
87
- });
88
-
89
- proc.stdout.on('data', (d) => {
90
- if (_io) _io.emit('bg-run-log', { runId: jobId, log: d.toString() });
91
- });
92
- proc.stderr.on('data', (d) => {
93
- if (_io) _io.emit('bg-run-log', { runId: jobId, log: `[ERROR] ${d.toString()}` });
94
- });
95
-
96
- proc.on('close', async (code) => {
97
- clearInterval(ssPoller);
98
- fs.rm(ssDir, { recursive: true, force: true }, () => {});
99
-
100
- let reportId = null;
101
- try {
102
- // Find the latest report created after this job started
103
- const report = await prisma.report.findFirst({
104
- where: { createdAt: { gte: new Date(startedAt) } },
105
- orderBy: { createdAt: 'desc' },
106
- select: { id: true, status: true }
73
+ const onLog = (text) => {
74
+ if (_io) _io.emit('bg-run-log', { runId: jobId, log: text });
75
+ };
76
+
77
+ function runAttempt(currentTag, suppressSave) {
78
+ return new Promise((resolve) => {
79
+ const ssDir = path.join(os.tmpdir(), `plum-trigger-ss-${jobId}-${Date.now()}`);
80
+ fs.mkdirSync(ssDir, { recursive: true });
81
+
82
+ const env = {
83
+ ...process.env,
84
+ TAG: currentTag,
85
+ TRIGGER: trigger,
86
+ BROWSER: browser,
87
+ REPORT_RUNNERS: String(workers),
88
+ PLUM_SS_DIR: ssDir
89
+ };
90
+ if (Number(workers) > 1) env.PARALLEL = String(workers);
91
+ if (testRunId) env.TEST_RUN_ID = testRunId;
92
+ if (baseUrl) env.BASE_URL = baseUrl;
93
+ if (suppressSave) env.PLUM_MODE = 'node';
94
+
95
+ const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
96
+
97
+ const ssPoller = startSsPoller(ssDir, ({ stepName, data }) => {
98
+ if (_io) _io.emit('bg-run-screenshot', { runId: jobId, stepName, data });
107
99
  });
108
- reportId = report?.id ?? null;
109
- if (reportId) {
110
- await prisma.report.update({
111
- where: { id: reportId },
112
- data: { duration: Date.now() - startedAt }
100
+
101
+ proc.stdout.on('data', (d) => onLog(d.toString()));
102
+ proc.stderr.on('data', (d) => onLog(`[ERROR] ${d.toString()}`));
103
+
104
+ proc.on('close', (code) => {
105
+ clearInterval(ssPoller);
106
+ fs.rm(ssDir, { recursive: true, force: true }, () => {});
107
+ resolve({ code, raw: suppressSave ? readCucumberReportFile() : null });
108
+ });
109
+ });
110
+ }
111
+
112
+ if (maxRetries === 0) {
113
+ runAttempt(tag, false).then(async ({ code }) => {
114
+ let reportId = null;
115
+ try {
116
+ // Find the latest report created after this job started
117
+ const report = await prisma.report.findFirst({
118
+ where: { createdAt: { gte: new Date(startedAt) } },
119
+ orderBy: { createdAt: 'desc' },
120
+ select: { id: true, status: true }
121
+ });
122
+ reportId = report?.id ?? null;
123
+ if (reportId) {
124
+ await prisma.report.update({
125
+ where: { id: reportId },
126
+ data: { duration: Date.now() - startedAt }
127
+ });
128
+ }
129
+ jobs.set(jobId, {
130
+ status: code === 130 ? 'cancelled' : 'done',
131
+ exitCode: code,
132
+ reportId,
133
+ startedAt
113
134
  });
135
+ } catch {
136
+ jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
137
+ }
138
+
139
+ if (_io) _io.emit('bg-run-done', { runId: jobId, code, reportId });
140
+ });
141
+ } else {
142
+ runWithRetries({
143
+ maxRetries,
144
+ spawnAttempt: async (tagOverride) => {
145
+ const { code, raw } = await runAttempt(tagOverride ?? tag, true);
146
+ return { code, rawJson: raw ? JSON.parse(raw) : [] };
147
+ },
148
+ onLog
149
+ }).then(async ({ code, rawJson, attempts }) => {
150
+ let reportId = null;
151
+ try {
152
+ const report = await reportService.saveReport({
153
+ rawCucumberJson: rawJson,
154
+ tags: tag,
155
+ triggerType: trigger,
156
+ browser,
157
+ testRunId: testRunId ?? null,
158
+ duration: Date.now() - startedAt,
159
+ attempts
160
+ });
161
+ reportId = report.id;
162
+ jobs.set(jobId, {
163
+ status: code === 130 ? 'cancelled' : 'done',
164
+ exitCode: code,
165
+ reportId,
166
+ startedAt
167
+ });
168
+ } catch {
169
+ jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
114
170
  }
115
- jobs.set(jobId, {
116
- status: code === 130 ? 'cancelled' : 'done',
117
- exitCode: code,
118
- reportId,
119
- startedAt
120
- });
121
- } catch {
122
- jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
123
- }
124
171
 
125
- if (_io) _io.emit('bg-run-done', { runId: jobId, code, reportId });
126
- });
172
+ if (_io) _io.emit('bg-run-done', { runId: jobId, code, reportId });
173
+ });
174
+ }
127
175
 
128
176
  res.status(202).json({ jobId, status: 'running' });
129
177
  } catch (e) {
@@ -88,6 +88,17 @@ async function deleteRunner(id) {
88
88
  }
89
89
  }
90
90
 
91
+ /**
92
+ * Stops/restarts a runner over the network via the primary's control routes,
93
+ * which hit the runner's own /api/shutdown|restart endpoints — works for any
94
+ * reachable runner, not just ones whose process this manager owns by PID.
95
+ */
96
+ async function controlRunner(id, action) {
97
+ const res = await fetch(`${API_URL}/runners/${id}/${action}`, { method: 'POST' });
98
+ const body = await res.json().catch(() => ({}));
99
+ if (!res.ok || body.ok === false) throw new Error(body.error || `HTTP ${res.status}`);
100
+ }
101
+
91
102
  /**
92
103
  * Resolves the display + control state for every runner: reachability (ping),
93
104
  * whether we own a live process for it, and whether we can control it at all.
@@ -156,12 +167,8 @@ function prepareNodeEnv() {
156
167
  async function runAction(r) {
157
168
  const options = [];
158
169
 
159
- if (!r.local) {
160
- clack.log.info(
161
- pc.dim(`"${r.name}" runs on another machine — it can be pinged but not controlled here.`)
162
- );
163
- options.push({ value: 'ping', label: 'Ping' });
164
- } else if (r.managed) {
170
+ if (r.managed) {
171
+ // Local, and this manager owns its process — control it directly by PID.
165
172
  options.push(
166
173
  { value: 'stop', label: pc.red('Stop') },
167
174
  { value: 'restart', label: pc.yellow('Restart') },
@@ -169,14 +176,18 @@ async function runAction(r) {
169
176
  { value: 'ping', label: 'Ping' }
170
177
  );
171
178
  } else if (r.online) {
172
- clack.log.info(
173
- pc.dim(
174
- `"${r.name}" is up but was not started by this manager — stop it from its own terminal.`
175
- )
179
+ // Remote, or local but started outside this manager (no PID to own) —
180
+ // either way the runner's own /api/shutdown|restart endpoints are
181
+ // reachable over the network via the primary's control routes.
182
+ options.push(
183
+ { value: 'stop', label: pc.red('Stop') },
184
+ { value: 'restart', label: pc.yellow('Restart') },
185
+ { value: 'ping', label: 'Ping' }
176
186
  );
177
- options.push({ value: 'ping', label: 'Ping' });
178
- } else {
187
+ } else if (r.local) {
179
188
  options.push({ value: 'start', label: pc.green('Start') }, { value: 'ping', label: 'Ping' });
189
+ } else {
190
+ options.push({ value: 'ping', label: 'Ping' });
180
191
  }
181
192
 
182
193
  options.push(
@@ -194,15 +205,39 @@ async function runAction(r) {
194
205
  const entry = startNode({ id: r.id, port, token: r.token });
195
206
  clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
196
207
  } else if (action === 'stop') {
197
- const ok = stopNode(r.id);
198
- clack.log.success(ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`));
208
+ if (r.managed) {
209
+ const ok = stopNode(r.id);
210
+ clack.log.success(
211
+ ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`)
212
+ );
213
+ } else {
214
+ const s = clack.spinner();
215
+ s.start(`Stopping "${r.name}"...`);
216
+ try {
217
+ await controlRunner(r.id, 'stop');
218
+ s.stop(pc.green(`Stopped "${r.name}"`));
219
+ } catch (e) {
220
+ s.stop(pc.red(`Could not stop "${r.name}": ${e.message}`));
221
+ }
222
+ }
199
223
  } else if (action === 'restart') {
200
- const s = clack.spinner();
201
- s.start(`Restarting "${r.name}"...`);
202
- stopNode(r.id);
203
- await new Promise((resolve) => setTimeout(resolve, 600));
204
- const entry = startNode({ id: r.id, port, token: r.token });
205
- s.stop(pc.green(`Restarted "${r.name}" (pid ${entry.pid})`));
224
+ if (r.managed) {
225
+ const s = clack.spinner();
226
+ s.start(`Restarting "${r.name}"...`);
227
+ stopNode(r.id);
228
+ await new Promise((resolve) => setTimeout(resolve, 600));
229
+ const entry = startNode({ id: r.id, port, token: r.token });
230
+ s.stop(pc.green(`Restarted "${r.name}" (pid ${entry.pid})`));
231
+ } else {
232
+ const s = clack.spinner();
233
+ s.start(`Restarting "${r.name}"...`);
234
+ try {
235
+ await controlRunner(r.id, 'restart');
236
+ s.stop(pc.green(`Restarted "${r.name}"`));
237
+ } catch (e) {
238
+ s.stop(pc.red(`Could not restart "${r.name}": ${e.message}`));
239
+ }
240
+ }
206
241
  } else if (action === 'log') {
207
242
  const entry = runnerProcess.loadRegistry()[r.id];
208
243
  clack.note(entry?.logFile ?? '(no log file)', 'Log file');
package/backend/server.js CHANGED
@@ -40,6 +40,20 @@ if (!fs.existsSync(testsDir)) {
40
40
  const isNodeMode = process.env.PLUM_MODE === 'node';
41
41
  const port = parseInt(process.env.PORT || '3001', 10);
42
42
 
43
+ // A self-restart (POST /api/restart) spawns the replacement before this
44
+ // process has released the port, so the first bind attempt can briefly hit
45
+ // EADDRINUSE — retry instead of dying immediately.
46
+ let listenRetriesLeft = 20;
47
+ server.on('error', (err) => {
48
+ if (err.code === 'EADDRINUSE' && listenRetriesLeft > 0) {
49
+ listenRetriesLeft -= 1;
50
+ setTimeout(() => server.listen(port), 250);
51
+ } else {
52
+ console.error(`❌ Failed to bind port ${port}:`, err.message);
53
+ process.exit(1);
54
+ }
55
+ });
56
+
43
57
  let cronService = null;
44
58
  let backupCronService = null;
45
59
  if (!isNodeMode) {
@@ -77,8 +91,13 @@ async function start() {
77
91
  const cleanup = () => {
78
92
  try {
79
93
  const reg = loadRegistry();
80
- delete reg[runnerId];
81
- saveRegistry(reg);
94
+ // A self-restart already wrote the replacement's pid under this
95
+ // id before this process exits — only clear the entry if it's
96
+ // still ours, so we don't erase the new process's registration.
97
+ if (reg[runnerId]?.pid === process.pid) {
98
+ delete reg[runnerId];
99
+ saveRegistry(reg);
100
+ }
82
101
  } catch {}
83
102
  };
84
103
  process.once('SIGTERM', cleanup);