plum-e2e 1.2.4 → 1.3.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/CLAUDE.md +201 -0
- package/README.md +245 -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 +13 -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 +160 -31
- 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 +325 -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 +1 -1
- 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;
|
package/bin/plum.js
CHANGED
|
@@ -25,6 +25,7 @@ import fse from 'fs-extra';
|
|
|
25
25
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
26
|
const __dirname = path.dirname(__filename);
|
|
27
27
|
const command = process.argv[2];
|
|
28
|
+
const subcommand = process.argv[3];
|
|
28
29
|
const plumRoot = path.resolve(__dirname, '..');
|
|
29
30
|
const userTestsPath = path.join(process.cwd(), 'tests');
|
|
30
31
|
const scaffoldTestsPath = path.join(plumRoot, 'backend', '_scaffold');
|
|
@@ -99,7 +100,7 @@ function installPlugins() {
|
|
|
99
100
|
// Ensure user's .gitignore contains Plum-generated entries
|
|
100
101
|
function ensureGitignore() {
|
|
101
102
|
const gitignorePath = path.join(process.cwd(), '.gitignore');
|
|
102
|
-
const plumEntries = ['reports/'];
|
|
103
|
+
const plumEntries = ['.env', 'reports/'];
|
|
103
104
|
const plumBlock = `\n# Plum (auto-generated)\n${plumEntries.join('\n')}\n`;
|
|
104
105
|
|
|
105
106
|
if (!fs.existsSync(gitignorePath)) {
|
|
@@ -243,52 +244,91 @@ switch (command) {
|
|
|
243
244
|
'',
|
|
244
245
|
'Powered by [Plum](https://github.com/silverlunah/plum) — Playwright + Cucumber.',
|
|
245
246
|
'',
|
|
247
|
+
'## Getting Started',
|
|
248
|
+
'',
|
|
249
|
+
"Your project is ready. Here's what to do next:",
|
|
250
|
+
'',
|
|
251
|
+
"1. **Open `.env`** and set `BASE_URL` to your application's URL.",
|
|
252
|
+
'2. **Run the example tests** to confirm everything works:',
|
|
253
|
+
' ```bash',
|
|
254
|
+
' plum run-test',
|
|
255
|
+
' ```',
|
|
256
|
+
'3. **Write your first test** — edit a file in `tests/features/` or generate a step:',
|
|
257
|
+
' ```bash',
|
|
258
|
+
' plum create-step',
|
|
259
|
+
' ```',
|
|
260
|
+
'4. **Start the full UI** (requires Docker) to trigger tests and view reports in the browser:',
|
|
261
|
+
' ```bash',
|
|
262
|
+
' plum start',
|
|
263
|
+
' ```',
|
|
264
|
+
' Then open **http://localhost:5173**.',
|
|
265
|
+
'',
|
|
266
|
+
'---',
|
|
267
|
+
'',
|
|
246
268
|
'## Commands',
|
|
247
269
|
'',
|
|
248
270
|
'| Command | Description |',
|
|
249
|
-
'
|
|
250
|
-
'| `plum
|
|
251
|
-
'| `plum
|
|
252
|
-
'| `plum
|
|
253
|
-
'| `plum
|
|
271
|
+
'| --- | --- |',
|
|
272
|
+
'| `plum run-test` | Run all tests locally |',
|
|
273
|
+
'| `plum run-test @tag` | Run tests matching a tag |',
|
|
274
|
+
'| `plum run-test --parallel N` | Run tests across N parallel workers |',
|
|
275
|
+
'| `plum run-test --browser firefox` | Run in a specific browser (chromium/firefox/webkit) |',
|
|
276
|
+
'| `plum start` | Start the full UI via Docker |',
|
|
277
|
+
'| `plum stop` | Stop the server |',
|
|
254
278
|
'| `plum create-step` | Interactively generate a new step definition |',
|
|
255
279
|
'',
|
|
280
|
+
'---',
|
|
281
|
+
'',
|
|
256
282
|
'## Configuration',
|
|
257
283
|
'',
|
|
258
284
|
'| File | Purpose |',
|
|
259
|
-
'
|
|
260
|
-
'| `.env` | Set `BASE_URL` and `IS_HEADLESS` |',
|
|
261
|
-
'| `plum.plugins.json` | Add extra npm packages
|
|
285
|
+
'| --- | --- |',
|
|
286
|
+
'| `.env` | Set `BASE_URL` (your app) and `IS_HEADLESS` (`true`/`false`) |',
|
|
287
|
+
'| `plum.plugins.json` | Add extra npm packages your tests need |',
|
|
288
|
+
'',
|
|
289
|
+
'---',
|
|
262
290
|
'',
|
|
263
291
|
'## Test Structure',
|
|
264
292
|
'',
|
|
265
293
|
'```',
|
|
266
294
|
'tests/',
|
|
267
|
-
' features/ — Gherkin .feature files',
|
|
295
|
+
' features/ — Gherkin .feature files (write your scenarios here)',
|
|
268
296
|
' step_definitions/ — TypeScript step implementations',
|
|
269
297
|
' pages/ — Page Object Models',
|
|
270
|
-
' utils/ — Browser setup, hooks, helpers',
|
|
298
|
+
' utils/ — Browser setup, hooks, shared helpers',
|
|
271
299
|
'```',
|
|
272
300
|
'',
|
|
273
|
-
'
|
|
301
|
+
'Each scenario needs a unique tag so you can run it by itself:',
|
|
274
302
|
'',
|
|
275
303
|
'```gherkin',
|
|
276
304
|
'@suite-login',
|
|
277
305
|
'Feature: Login',
|
|
278
306
|
'',
|
|
279
307
|
' @test-login-1',
|
|
280
|
-
' Scenario: User can log in',
|
|
308
|
+
' Scenario: User can log in with valid credentials',
|
|
281
309
|
' Given I am on the login page',
|
|
282
|
-
'
|
|
310
|
+
' When I enter valid credentials',
|
|
311
|
+
' Then I should see the dashboard',
|
|
283
312
|
'```',
|
|
284
313
|
'',
|
|
285
314
|
'```bash',
|
|
286
|
-
'plum
|
|
287
|
-
'plum
|
|
288
|
-
'```'
|
|
315
|
+
'plum run-test @test-login-1 # run a single scenario',
|
|
316
|
+
'plum run-test @suite-login # run the whole suite',
|
|
317
|
+
'```',
|
|
318
|
+
'',
|
|
319
|
+
'---',
|
|
320
|
+
'',
|
|
321
|
+
'## Cucumber & Gherkin Resources',
|
|
322
|
+
'',
|
|
323
|
+
'New to Cucumber? These links will get you up to speed quickly:',
|
|
324
|
+
'',
|
|
325
|
+
'- [Gherkin syntax reference](https://cucumber.io/docs/gherkin/reference/) — Feature files, Scenarios, Given/When/Then, tags, Scenario Outlines',
|
|
326
|
+
'- [Step definitions guide](https://cucumber.io/docs/cucumber/step-definitions/) — Connecting Gherkin steps to TypeScript code',
|
|
327
|
+
'- [Playwright docs](https://playwright.dev/docs/intro) — Browser automation API used inside page objects',
|
|
328
|
+
'- [Plum documentation](https://github.com/silverlunah/plum) — Full README and reference'
|
|
289
329
|
].join('\n');
|
|
290
330
|
fs.writeFileSync(userReadmePath, readmeContent + '\n', 'utf8');
|
|
291
|
-
console.log('✅ README.md created with
|
|
331
|
+
console.log('✅ README.md created with quick-start guide.\n');
|
|
292
332
|
} else {
|
|
293
333
|
console.log('⚠️ README.md already exists. Skipping.\n');
|
|
294
334
|
}
|
|
@@ -303,11 +343,23 @@ switch (command) {
|
|
|
303
343
|
});
|
|
304
344
|
|
|
305
345
|
console.log(
|
|
306
|
-
'🟣 Plum is now ready!\n\n Scaffold test cases are in `tests/`.\n Add extra npm packages to `plum.plugins.json`.\n\n - Run tests locally:\n `plum
|
|
346
|
+
'🟣 Plum is now ready!\n\n Scaffold test cases are in `tests/`.\n Add extra npm packages to `plum.plugins.json`.\n\n - Run tests locally:\n `plum run-test` or `plum run-test @tag`\n\n - Start the full UI (requires Docker):\n `plum start`\n\n - Generate a step:\n `plum create-step`'
|
|
307
347
|
);
|
|
308
348
|
console.log('--------------------------------------\n');
|
|
309
349
|
break;
|
|
310
350
|
|
|
351
|
+
case 'server':
|
|
352
|
+
if (subcommand === 'stop') {
|
|
353
|
+
console.log('--------------------------------------\n');
|
|
354
|
+
console.log('🛑 Stopping Plum server...');
|
|
355
|
+
execSync('docker compose down', { cwd: plumRoot, stdio: 'inherit' });
|
|
356
|
+
console.log('✅ Plum server stopped. Your data is preserved.\n');
|
|
357
|
+
console.log('--------------------------------------\n');
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
// fall through to start for 'plum server start' or 'plum server'
|
|
361
|
+
// intentional fall-through
|
|
362
|
+
|
|
311
363
|
case 'start':
|
|
312
364
|
console.log('--------------------------------------\n');
|
|
313
365
|
|
|
@@ -361,20 +413,30 @@ switch (command) {
|
|
|
361
413
|
console.log('--------------------------------------\n');
|
|
362
414
|
break;
|
|
363
415
|
|
|
364
|
-
case '
|
|
416
|
+
case 'run-test': {
|
|
365
417
|
console.log('--------------------------------------\n');
|
|
366
|
-
console.log('🚀 Running
|
|
418
|
+
console.log('🚀 Running tests locally...');
|
|
367
419
|
|
|
368
420
|
// Copy .env file from root to backend
|
|
369
421
|
copyEnvFile();
|
|
370
422
|
|
|
371
|
-
const
|
|
372
|
-
const parallelIdx =
|
|
373
|
-
const parallelArg = parallelIdx !== -1 ?
|
|
374
|
-
const
|
|
423
|
+
const runArgs = process.argv.slice(3);
|
|
424
|
+
const parallelIdx = runArgs.indexOf('--parallel');
|
|
425
|
+
const parallelArg = parallelIdx !== -1 ? runArgs[parallelIdx + 1] : null;
|
|
426
|
+
const browserIdx = runArgs.indexOf('--browser');
|
|
427
|
+
const browserArg = browserIdx !== -1 ? runArgs[browserIdx + 1] : null;
|
|
428
|
+
const tagArg = runArgs.find((a) => a.startsWith('@')) ?? null;
|
|
375
429
|
const userTestsPath = path.resolve(process.cwd(), 'tests');
|
|
376
430
|
const backendTestsPath = path.join(plumRoot, 'backend', 'tests');
|
|
377
431
|
|
|
432
|
+
const validBrowsers = ['chromium', 'firefox', 'webkit'];
|
|
433
|
+
if (browserArg && !validBrowsers.includes(browserArg)) {
|
|
434
|
+
console.error(
|
|
435
|
+
`✗ Invalid browser "${browserArg}". Choose one of: ${validBrowsers.join(', ')}`
|
|
436
|
+
);
|
|
437
|
+
process.exit(1);
|
|
438
|
+
}
|
|
439
|
+
|
|
378
440
|
// Copy user tests into backend
|
|
379
441
|
if (fs.existsSync(userTestsPath)) {
|
|
380
442
|
console.log('📦 Syncing your tests...\n');
|
|
@@ -407,6 +469,7 @@ switch (command) {
|
|
|
407
469
|
console.log('Running `npm run test` with:');
|
|
408
470
|
console.log('TAG =', tagArg ?? '');
|
|
409
471
|
console.log('PARALLEL =', parallelArg ?? 'off');
|
|
472
|
+
console.log('BROWSER =', browserArg ?? 'chromium');
|
|
410
473
|
console.log('TRIGGER =', 'command-line-trigger');
|
|
411
474
|
|
|
412
475
|
execSync('npm run test', {
|
|
@@ -416,7 +479,8 @@ switch (command) {
|
|
|
416
479
|
...process.env,
|
|
417
480
|
TAG: tagArg ?? '',
|
|
418
481
|
TRIGGER: 'command-line-trigger',
|
|
419
|
-
...(parallelArg ? { PARALLEL: parallelArg } : {})
|
|
482
|
+
...(parallelArg ? { PARALLEL: parallelArg } : {}),
|
|
483
|
+
...(browserArg ? { BROWSER: browserArg } : {})
|
|
420
484
|
}
|
|
421
485
|
});
|
|
422
486
|
console.log('--------------------------------------\n');
|
|
@@ -434,6 +498,64 @@ switch (command) {
|
|
|
434
498
|
console.log('--------------------------------------\n');
|
|
435
499
|
break;
|
|
436
500
|
|
|
501
|
+
case 'node': {
|
|
502
|
+
if (subcommand === 'stop') {
|
|
503
|
+
console.log('--------------------------------------\n');
|
|
504
|
+
console.log('🛑 Stopping Plum node...');
|
|
505
|
+
execSync('docker compose -f docker-compose.node.yml down', {
|
|
506
|
+
cwd: plumRoot,
|
|
507
|
+
stdio: 'inherit'
|
|
508
|
+
});
|
|
509
|
+
console.log('✅ Plum node stopped.\n');
|
|
510
|
+
console.log('--------------------------------------\n');
|
|
511
|
+
break;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// 'plum node start' — parse --token and --primary flags
|
|
515
|
+
const nodeArgs = process.argv.slice(3);
|
|
516
|
+
const tokenIdx = nodeArgs.indexOf('--token');
|
|
517
|
+
const nodeToken = tokenIdx !== -1 ? nodeArgs[tokenIdx + 1] : process.env.NODE_TOKEN || '';
|
|
518
|
+
const primaryIdx = nodeArgs.indexOf('--primary');
|
|
519
|
+
const primaryUrl = primaryIdx !== -1 ? nodeArgs[primaryIdx + 1] : process.env.PRIMARY_URL || '';
|
|
520
|
+
|
|
521
|
+
console.log('--------------------------------------\n');
|
|
522
|
+
console.log('🚀 Starting Plum node (runner mode)...');
|
|
523
|
+
if (!nodeToken) {
|
|
524
|
+
console.log(
|
|
525
|
+
'⚠️ No --token provided. The node will accept requests without authentication.\n'
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Build override for node mode
|
|
530
|
+
const userReportsAbs = path.resolve(process.cwd(), 'reports').replace(/\\/g, '/');
|
|
531
|
+
const nodeOverridePath = path.join(plumRoot, 'docker-compose.node-override.yml');
|
|
532
|
+
|
|
533
|
+
const nodeOverride = [
|
|
534
|
+
'services:',
|
|
535
|
+
' backend:',
|
|
536
|
+
' volumes:',
|
|
537
|
+
` - "${userReportsAbs}:/app/reports"`,
|
|
538
|
+
' environment:',
|
|
539
|
+
` NODE_TOKEN: "${nodeToken}"`,
|
|
540
|
+
` PRIMARY_URL: "${primaryUrl}"`,
|
|
541
|
+
' PLUM_MODE: "node"'
|
|
542
|
+
].join('\n');
|
|
543
|
+
|
|
544
|
+
fs.writeFileSync(nodeOverridePath, nodeOverride + '\n', 'utf8');
|
|
545
|
+
|
|
546
|
+
copyEnvFile();
|
|
547
|
+
|
|
548
|
+
execSync(
|
|
549
|
+
'docker compose -f docker-compose.node.yml -f docker-compose.node-override.yml up --build',
|
|
550
|
+
{
|
|
551
|
+
cwd: plumRoot,
|
|
552
|
+
stdio: 'inherit'
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
console.log('--------------------------------------\n');
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
|
|
437
559
|
case 'create-step': {
|
|
438
560
|
const createStepScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-step.mjs');
|
|
439
561
|
execSync(`node "${createStepScript}"`, {
|
|
@@ -450,10 +572,17 @@ switch (command) {
|
|
|
450
572
|
default:
|
|
451
573
|
console.log('--------------------------------------\n');
|
|
452
574
|
console.log('Usage: plum <command>\n');
|
|
453
|
-
console.log(' init
|
|
454
|
-
console.log(' start Start the full UI stack via Docker');
|
|
455
|
-
console.log(' stop Stop
|
|
456
|
-
console.log('
|
|
457
|
-
console.log('
|
|
575
|
+
console.log(' init Set up a new Plum project');
|
|
576
|
+
console.log(' server start Start the full UI stack via Docker (alias: plum start)');
|
|
577
|
+
console.log(' server stop Stop the server (alias: plum stop)');
|
|
578
|
+
console.log(' node start Start a runner node (no UI, receives remote jobs)');
|
|
579
|
+
console.log(' --token <secret> Auth token the primary must send');
|
|
580
|
+
console.log(' --primary <url> URL of the primary Plum server');
|
|
581
|
+
console.log(' node stop Stop the runner node');
|
|
582
|
+
console.log(' run-test Run tests locally without Docker');
|
|
583
|
+
console.log(' @tag Run only tests matching a tag');
|
|
584
|
+
console.log(' --parallel <n> Run across n parallel workers');
|
|
585
|
+
console.log(' --browser <name> chromium | firefox | webkit (default: chromium)');
|
|
586
|
+
console.log(' create-step Interactively scaffold a new step definition');
|
|
458
587
|
console.log('\n--------------------------------------\n');
|
|
459
588
|
}
|