plum-e2e 1.2.3 → 1.3.0
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 +201 -0
- package/README.md +237 -90
- package/backend/_scaffold/utils/browser.ts +5 -2
- package/backend/app.js +9 -1
- package/backend/config/scripts/generate-report.js +34 -73
- package/backend/config/scripts/run-tests.js +7 -3
- package/backend/constants/triggers.js +67 -0
- package/backend/lib/reportFilename.js +37 -0
- package/backend/lib/testChunker.js +73 -0
- package/backend/middleware/auth.js +32 -0
- package/backend/package.json +4 -2
- package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
- package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
- package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
- package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
- package/backend/prisma/schema.prisma +21 -1
- package/backend/routes/cron.routes.js +28 -0
- package/backend/routes/node.routes.js +121 -0
- package/backend/routes/reports.routes.js +23 -20
- package/backend/routes/runners.routes.js +83 -0
- package/backend/scripts/add-local-runner.js +120 -0
- package/backend/scripts/create-test.js +148 -0
- package/backend/server.js +16 -7
- package/backend/services/backupService.js +3 -30
- package/backend/services/cronService.js +220 -36
- package/backend/services/reportService.js +227 -55
- package/backend/services/runnerService.js +179 -0
- package/backend/websockets/socketHandler.js +162 -21
- package/bin/plum.js +191 -47
- package/docker-compose.node.yml +59 -0
- package/docker-compose.yml +2 -0
- package/frontend/package.json +1 -4
- package/frontend/src/app.css +20 -254
- package/frontend/src/app.html +1 -1
- package/frontend/src/lib/api/reports.js +17 -36
- package/frontend/src/lib/api/runners.js +61 -0
- package/frontend/src/lib/api/schedules.js +34 -5
- package/frontend/src/lib/api/settings.js +5 -5
- package/frontend/src/lib/api/tests.js +2 -19
- package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
- package/frontend/src/lib/components/layout/Nav.svelte +42 -47
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
- package/frontend/src/lib/components/ui/Badge.svelte +6 -1
- package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
- package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
- package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
- package/frontend/src/lib/constants.js +36 -0
- package/frontend/src/lib/stores/runner.js +23 -12
- package/frontend/src/lib/styles/global.css +176 -0
- package/frontend/src/lib/styles/reset.css +86 -0
- package/frontend/src/lib/styles/tokens.css +90 -0
- package/frontend/src/lib/utils/format.js +46 -0
- package/frontend/src/routes/+page.svelte +16 -35
- package/frontend/src/routes/reports/+page.svelte +84 -167
- package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
- package/frontend/src/routes/reports/live/+page.svelte +704 -0
- package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
- package/frontend/src/routes/settings/+page.svelte +774 -127
- package/frontend/static/favicon-32x32.png +0 -0
- package/frontend/static/favicon.ico +0 -0
- package/package.json +2 -2
- package/frontend/static/favicon.png +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
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 fs = require('fs');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const prisma = require('./prisma');
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Runner CRUD
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const getAll = () => prisma.runner.findMany({ orderBy: { createdAt: 'asc' } });
|
|
27
|
+
|
|
28
|
+
const create = ({ name, url, token, browser = 'chromium' }) =>
|
|
29
|
+
prisma.runner.create({ data: { name, url, token, browser } });
|
|
30
|
+
|
|
31
|
+
const remove = (id) => prisma.runner.delete({ where: { id } });
|
|
32
|
+
|
|
33
|
+
const update = (id, data) => prisma.runner.update({ where: { id }, data });
|
|
34
|
+
|
|
35
|
+
const getById = (id) => prisma.runner.findUnique({ where: { id } });
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Connectivity
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
async function probe({ url, token }) {
|
|
42
|
+
const start = Date.now();
|
|
43
|
+
try {
|
|
44
|
+
const res = await fetch(`${url}/api/ping`, {
|
|
45
|
+
method: 'GET',
|
|
46
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
47
|
+
signal: AbortSignal.timeout(5000)
|
|
48
|
+
});
|
|
49
|
+
return { ok: res.ok, latency: Date.now() - start };
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return { ok: false, error: e.message };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function ping(id) {
|
|
56
|
+
const runner = await getById(id);
|
|
57
|
+
if (!runner) return { ok: false, error: 'Runner not found' };
|
|
58
|
+
return probe({ url: runner.url, token: runner.token });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Remote execution
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
function collectTestFiles() {
|
|
66
|
+
const testsDir = path.resolve(process.cwd(), 'tests');
|
|
67
|
+
const files = {};
|
|
68
|
+
|
|
69
|
+
function walk(dir, rel) {
|
|
70
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
71
|
+
const fullPath = path.join(dir, entry.name);
|
|
72
|
+
const relPath = rel ? `${rel}/${entry.name}` : entry.name;
|
|
73
|
+
if (entry.isDirectory()) {
|
|
74
|
+
walk(fullPath, relPath);
|
|
75
|
+
} else {
|
|
76
|
+
files[relPath] = fs.readFileSync(fullPath, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (fs.existsSync(testsDir)) walk(testsDir, '');
|
|
82
|
+
return files;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetches the raw cucumber JSON content from a finished remote node job.
|
|
87
|
+
* Returns the content string, or null on failure.
|
|
88
|
+
*/
|
|
89
|
+
async function fetchReportContent(runner, jobId, onLog) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(`${runner.url}/api/report/${jobId}`, {
|
|
92
|
+
headers: { Authorization: `Bearer ${runner.token}` },
|
|
93
|
+
signal: AbortSignal.timeout(15000)
|
|
94
|
+
});
|
|
95
|
+
if (!res.ok) {
|
|
96
|
+
onLog(`[WARN] Could not fetch report from "${runner.name}": HTTP ${res.status}\n`);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
const { content } = await res.json();
|
|
100
|
+
return content ?? null;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
onLog(`[WARN] Could not fetch report from "${runner.name}": ${e.message}\n`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Dispatches a test job to a remote runner node and polls until it finishes.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} runnerId
|
|
111
|
+
* @param {{ tags: string, browser: string, workers: number }} jobParams
|
|
112
|
+
* @param {(log: string) => void} onLog Called with each new log chunk
|
|
113
|
+
* @param {(exitCode: number, reportContent: string|null) => void} onDone
|
|
114
|
+
*/
|
|
115
|
+
async function dispatchAndPoll(runnerId, { tags, browser, workers }, onLog, onDone) {
|
|
116
|
+
const runner = await getById(runnerId);
|
|
117
|
+
if (!runner) {
|
|
118
|
+
onLog(`[ERROR] Runner ${runnerId} not found\n`);
|
|
119
|
+
onDone(1, null);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let jobId;
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(`${runner.url}/api/execute`, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Content-Type': 'application/json',
|
|
129
|
+
Authorization: `Bearer ${runner.token}`
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({ tags, browser, workers, tests: collectTestFiles() }),
|
|
132
|
+
signal: AbortSignal.timeout(10000)
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
135
|
+
jobId = (await res.json()).jobId;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
onLog(`[ERROR] Could not reach runner "${runner.name}": ${e.message}\n`);
|
|
138
|
+
onDone(1, null);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
onLog(`Connected to runner "${runner.name}" — job ${jobId}\n`);
|
|
143
|
+
|
|
144
|
+
let logOffset = 0;
|
|
145
|
+
const poll = setInterval(async () => {
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(`${runner.url}/api/execute/${jobId}?offset=${logOffset}`, {
|
|
148
|
+
headers: { Authorization: `Bearer ${runner.token}` },
|
|
149
|
+
signal: AbortSignal.timeout(8000)
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) return;
|
|
152
|
+
const body = await res.json();
|
|
153
|
+
|
|
154
|
+
if (body.logs) {
|
|
155
|
+
onLog(body.logs);
|
|
156
|
+
logOffset += body.logs.length;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (body.status === 'done' || body.status === 'error') {
|
|
160
|
+
clearInterval(poll);
|
|
161
|
+
const content = await fetchReportContent(runner, jobId, onLog);
|
|
162
|
+
onDone(body.exitCode ?? (body.status === 'done' ? 0 : 1), content);
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// transient polling error — keep trying
|
|
166
|
+
}
|
|
167
|
+
}, 2500);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
getAll,
|
|
172
|
+
create,
|
|
173
|
+
remove,
|
|
174
|
+
update,
|
|
175
|
+
getById,
|
|
176
|
+
probe,
|
|
177
|
+
ping,
|
|
178
|
+
dispatchAndPoll
|
|
179
|
+
};
|
|
@@ -16,37 +16,178 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { spawn } = require('child_process');
|
|
19
|
+
const runnerService = require('../services/runnerService');
|
|
20
|
+
const reportService = require('../services/reportService');
|
|
21
|
+
const { TRIGGER_TYPE, BUILT_IN_RUNNER_ID, TRIGGER_REMOTE } = require('../constants/triggers');
|
|
22
|
+
const { getTestIdsForTag, chunkTests, buildTagExpression } = require('../lib/testChunker');
|
|
23
|
+
const { readCucumberReportFile } = require('../lib/reportFilename');
|
|
19
24
|
|
|
20
25
|
const socketHandler = (io) => {
|
|
21
26
|
io.on('connection', (socket) => {
|
|
22
27
|
console.log('WebSocket connection established');
|
|
23
|
-
socket.on('run-test', (testID, workers) => {
|
|
24
|
-
const tag = testID ? `${testID}` : '';
|
|
25
|
-
const runnerCount = Number(workers) > 1 ? Number(workers) : 1;
|
|
26
|
-
const env = {
|
|
27
|
-
...process.env,
|
|
28
|
-
TAG: tag,
|
|
29
|
-
TRIGGER: 'manual-trigger',
|
|
30
|
-
REPORT_RUNNERS: String(runnerCount)
|
|
31
|
-
};
|
|
32
|
-
if (workers && workers > 1) env.PARALLEL = String(workers);
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
// Track processes spawned for this socket connection so we can cancel them
|
|
30
|
+
const activeProcs = new Set();
|
|
35
31
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
socket.on('run-test', async (payload, legacyWorkers) => {
|
|
33
|
+
let tag, workers, browser, runners;
|
|
34
|
+
if (typeof payload === 'string') {
|
|
35
|
+
tag = payload;
|
|
36
|
+
workers = Number(legacyWorkers) > 1 ? Number(legacyWorkers) : 1;
|
|
37
|
+
browser = 'chromium';
|
|
38
|
+
runners = [BUILT_IN_RUNNER_ID];
|
|
39
|
+
} else {
|
|
40
|
+
tag = payload.tag ?? '';
|
|
41
|
+
workers = Number(payload.workers) > 1 ? Number(payload.workers) : 1;
|
|
42
|
+
browser = payload.browser ?? 'chromium';
|
|
43
|
+
runners =
|
|
44
|
+
Array.isArray(payload.runners) && payload.runners.length > 0
|
|
45
|
+
? payload.runners
|
|
46
|
+
: [BUILT_IN_RUNNER_ID];
|
|
47
|
+
}
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
socket.emit('log', `[ERROR] ${data.toString()}`);
|
|
42
|
-
});
|
|
49
|
+
const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
51
|
+
if (isSingleBuiltIn) {
|
|
52
|
+
runBuiltIn(io, socket, activeProcs, tag, workers, browser);
|
|
53
|
+
} else {
|
|
54
|
+
runDistributed(io, socket, activeProcs, tag, workers, browser, runners);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
socket.on('cancel-test', () => {
|
|
59
|
+
if (activeProcs.size === 0) return;
|
|
60
|
+
for (const proc of activeProcs) {
|
|
61
|
+
try {
|
|
62
|
+
proc.kill('SIGTERM');
|
|
63
|
+
} catch {}
|
|
64
|
+
}
|
|
65
|
+
activeProcs.clear();
|
|
66
|
+
socket.emit('log', '\n[CANCELLED] Test run cancelled by user.\n');
|
|
67
|
+
socket.emit('done', 130);
|
|
48
68
|
});
|
|
49
69
|
});
|
|
50
70
|
};
|
|
51
71
|
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Single built-in runner
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
function runBuiltIn(io, socket, activeProcs, tag, workers, browser) {
|
|
77
|
+
const env = {
|
|
78
|
+
...process.env,
|
|
79
|
+
TAG: tag,
|
|
80
|
+
TRIGGER: TRIGGER_TYPE.MANUAL,
|
|
81
|
+
REPORT_RUNNERS: String(workers),
|
|
82
|
+
BROWSER: browser
|
|
83
|
+
};
|
|
84
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
85
|
+
|
|
86
|
+
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
87
|
+
activeProcs.add(proc);
|
|
88
|
+
|
|
89
|
+
proc.stdout.on('data', (d) => socket.emit('log', d.toString()));
|
|
90
|
+
proc.stderr.on('data', (d) => socket.emit('log', `[ERROR] ${d.toString()}`));
|
|
91
|
+
|
|
92
|
+
proc.on('close', (code) => {
|
|
93
|
+
activeProcs.delete(proc);
|
|
94
|
+
socket.emit('log', `\nTest finished with code ${code}`);
|
|
95
|
+
socket.emit('done', code);
|
|
96
|
+
// Notify all connected clients that a new report is available
|
|
97
|
+
io.emit('report-ready');
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Distributed (multi-runner) path
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
async function runDistributed(io, socket, activeProcs, tag, workers, browser, runnerIds) {
|
|
106
|
+
const allIds = getTestIdsForTag(tag);
|
|
107
|
+
const chunks = chunkTests(allIds, runnerIds.length);
|
|
108
|
+
|
|
109
|
+
const laneInfos = await Promise.all(
|
|
110
|
+
runnerIds.map(async (id) => {
|
|
111
|
+
if (id === BUILT_IN_RUNNER_ID) return { id, name: 'Built-in', dbId: null };
|
|
112
|
+
const r = await runnerService.getById(id);
|
|
113
|
+
return { id, name: r?.name ?? id, dbId: r?.id ?? null };
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
socket.emit(
|
|
118
|
+
'runner-lanes-init',
|
|
119
|
+
laneInfos.map((l, i) => ({ id: l.id, name: l.name, testCount: (chunks[i] || []).length }))
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const total = runnerIds.length;
|
|
123
|
+
const collectedReports = new Array(total).fill(null);
|
|
124
|
+
let doneCount = 0;
|
|
125
|
+
let overallCode = 0;
|
|
126
|
+
|
|
127
|
+
function onLaneDone(idx, laneId, code, reportContent) {
|
|
128
|
+
if (code !== 0) overallCode = code;
|
|
129
|
+
collectedReports[idx] = reportContent;
|
|
130
|
+
socket.emit('runner-lane-status', { id: laneId, status: code === 0 ? 'done' : 'error' });
|
|
131
|
+
doneCount++;
|
|
132
|
+
|
|
133
|
+
if (doneCount === total) {
|
|
134
|
+
socket.emit('log', `\nAll runners finished (exit ${overallCode})`);
|
|
135
|
+
socket.emit('done', overallCode);
|
|
136
|
+
|
|
137
|
+
reportService
|
|
138
|
+
.saveCombinedReport({
|
|
139
|
+
reports: collectedReports,
|
|
140
|
+
runners: laneInfos,
|
|
141
|
+
overallCode,
|
|
142
|
+
tag,
|
|
143
|
+
triggerType: TRIGGER_TYPE.MANUAL,
|
|
144
|
+
browser
|
|
145
|
+
})
|
|
146
|
+
.then(() => io.emit('report-ready'))
|
|
147
|
+
.catch((e) => console.error('[runner] Failed to save combined report:', e.message));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < runnerIds.length; i++) {
|
|
152
|
+
const lane = laneInfos[i];
|
|
153
|
+
const chunkTag = chunks[i]?.length > 0 ? buildTagExpression(chunks[i]) : tag;
|
|
154
|
+
|
|
155
|
+
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
156
|
+
const env = {
|
|
157
|
+
...process.env,
|
|
158
|
+
TAG: chunkTag,
|
|
159
|
+
TRIGGER: TRIGGER_REMOTE,
|
|
160
|
+
BROWSER: browser,
|
|
161
|
+
REPORT_RUNNERS: String(workers),
|
|
162
|
+
PLUM_MODE: 'node'
|
|
163
|
+
};
|
|
164
|
+
if (workers > 1) env.PARALLEL = String(workers);
|
|
165
|
+
|
|
166
|
+
const proc = spawn('npm', ['run', 'test'], { env, shell: true });
|
|
167
|
+
activeProcs.add(proc);
|
|
168
|
+
|
|
169
|
+
proc.stdout.on('data', (d) =>
|
|
170
|
+
socket.emit('runner-lane-log', { id: lane.id, log: d.toString() })
|
|
171
|
+
);
|
|
172
|
+
proc.stderr.on('data', (d) =>
|
|
173
|
+
socket.emit('runner-lane-log', { id: lane.id, log: `[ERROR] ${d.toString()}` })
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
const idx = i;
|
|
177
|
+
proc.on('close', (code) => {
|
|
178
|
+
activeProcs.delete(proc);
|
|
179
|
+
onLaneDone(idx, lane.id, code, readCucumberReportFile());
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
const idx = i;
|
|
183
|
+
runnerService.dispatchAndPoll(
|
|
184
|
+
lane.id,
|
|
185
|
+
{ tags: chunkTag, browser, workers },
|
|
186
|
+
(log) => socket.emit('runner-lane-log', { id: lane.id, log }),
|
|
187
|
+
(code, content) => onLaneDone(idx, lane.id, code, content)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
52
193
|
module.exports = socketHandler;
|