plum-e2e 2.6.0 → 2.6.1

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) {
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);