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 +4 -1
- package/backend/lib/retryRunner.js +61 -0
- package/backend/prisma/migrations/20260714070000_add_project_max_retries/migration.sql +2 -0
- package/backend/prisma/schema.prisma +1 -0
- package/backend/routes/node.routes.js +20 -0
- package/backend/routes/runners.routes.js +19 -10
- package/backend/routes/settings.routes.js +2 -2
- package/backend/routes/trigger.routes.js +103 -55
- package/backend/server.js +21 -2
- package/backend/services/cronService.js +185 -70
- package/backend/services/reportService.js +74 -6
- package/backend/services/runnerService.js +25 -0
- package/backend/services/settingsService.js +3 -2
- package/backend/websockets/socketHandler.js +199 -90
- package/frontend/src/lib/api/runners.js +10 -0
- package/frontend/src/lib/api/settings.js +3 -3
- package/frontend/src/lib/constants.js +1 -0
- package/frontend/src/routes/reports/[id]/+page.svelte +8 -0
- package/frontend/src/routes/settings/+page.svelte +85 -3
- package/package.json +1 -1
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
|
-
|
|
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 };
|
|
@@ -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
|
-
|
|
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
|
|
86
|
-
if (_io) _io.emit('bg-run-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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);
|