plum-e2e 1.2.4 → 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.
Files changed (62) hide show
  1. package/CLAUDE.md +201 -0
  2. package/README.md +237 -90
  3. package/backend/_scaffold/utils/browser.ts +5 -2
  4. package/backend/app.js +9 -1
  5. package/backend/config/scripts/generate-report.js +34 -73
  6. package/backend/config/scripts/run-tests.js +7 -3
  7. package/backend/constants/triggers.js +67 -0
  8. package/backend/lib/reportFilename.js +37 -0
  9. package/backend/lib/testChunker.js +73 -0
  10. package/backend/middleware/auth.js +32 -0
  11. package/backend/package.json +4 -2
  12. package/backend/prisma/migrations/20260616000000_add_runners_and_browser/migration.sql +26 -0
  13. package/backend/prisma/migrations/20260616000001_cron_runner_ids/migration.sql +6 -0
  14. package/backend/prisma/migrations/20260617000000_cron_enabled/migration.sql +1 -0
  15. package/backend/prisma/migrations/20260617000001_report_content/migration.sql +8 -0
  16. package/backend/prisma/schema.prisma +21 -1
  17. package/backend/routes/cron.routes.js +28 -0
  18. package/backend/routes/node.routes.js +121 -0
  19. package/backend/routes/reports.routes.js +23 -20
  20. package/backend/routes/runners.routes.js +83 -0
  21. package/backend/scripts/add-local-runner.js +120 -0
  22. package/backend/scripts/create-test.js +148 -0
  23. package/backend/server.js +16 -7
  24. package/backend/services/backupService.js +3 -30
  25. package/backend/services/cronService.js +220 -36
  26. package/backend/services/reportService.js +227 -55
  27. package/backend/services/runnerService.js +179 -0
  28. package/backend/websockets/socketHandler.js +162 -21
  29. package/bin/plum.js +132 -19
  30. package/docker-compose.node.yml +59 -0
  31. package/docker-compose.yml +2 -0
  32. package/frontend/package.json +1 -4
  33. package/frontend/src/app.css +20 -254
  34. package/frontend/src/app.html +1 -1
  35. package/frontend/src/lib/api/reports.js +17 -36
  36. package/frontend/src/lib/api/runners.js +61 -0
  37. package/frontend/src/lib/api/schedules.js +34 -5
  38. package/frontend/src/lib/api/settings.js +5 -5
  39. package/frontend/src/lib/api/tests.js +2 -19
  40. package/frontend/src/lib/components/icons/BrowserIcon.svelte +75 -0
  41. package/frontend/src/lib/components/layout/Nav.svelte +42 -47
  42. package/frontend/src/lib/components/layout/RunnerPanel.svelte +913 -253
  43. package/frontend/src/lib/components/ui/Badge.svelte +6 -1
  44. package/frontend/src/lib/components/ui/ConfirmModal.svelte +98 -0
  45. package/frontend/{tailwind.config.js → src/lib/components/ui/EmptyState.svelte} +27 -8
  46. package/frontend/{postcss.config.js → src/lib/components/ui/Toast.svelte} +20 -7
  47. package/frontend/src/lib/constants.js +36 -0
  48. package/frontend/src/lib/stores/runner.js +23 -12
  49. package/frontend/src/lib/styles/global.css +176 -0
  50. package/frontend/src/lib/styles/reset.css +86 -0
  51. package/frontend/src/lib/styles/tokens.css +90 -0
  52. package/frontend/src/lib/utils/format.js +46 -0
  53. package/frontend/src/routes/+page.svelte +16 -35
  54. package/frontend/src/routes/reports/+page.svelte +84 -167
  55. package/frontend/src/routes/reports/{[slug] → [id]}/+page.svelte +304 -76
  56. package/frontend/src/routes/reports/live/+page.svelte +704 -0
  57. package/frontend/src/routes/scheduled-tests/+page.svelte +328 -88
  58. package/frontend/src/routes/settings/+page.svelte +774 -127
  59. package/frontend/static/favicon-32x32.png +0 -0
  60. package/frontend/static/favicon.ico +0 -0
  61. package/package.json +1 -1
  62. 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
- const testProcess = spawn('npm', ['run', 'test'], { env, shell: true });
29
+ // Track processes spawned for this socket connection so we can cancel them
30
+ const activeProcs = new Set();
35
31
 
36
- testProcess.stdout.on('data', (data) => {
37
- socket.emit('log', data.toString());
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
- testProcess.stderr.on('data', (data) => {
41
- socket.emit('log', `[ERROR] ${data.toString()}`);
42
- });
49
+ const isSingleBuiltIn = runners.length === 1 && runners[0] === BUILT_IN_RUNNER_ID;
43
50
 
44
- testProcess.on('close', (code) => {
45
- socket.emit('log', `\nTest finished with code ${code}`);
46
- socket.emit('done', code);
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');
@@ -243,52 +244,90 @@ 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 dev',
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
- '|---|---|',
271
+ '| --- | --- |',
250
272
  '| `plum dev` | Run all tests locally |',
251
273
  '| `plum dev @tag` | Run tests matching a tag |',
252
274
  '| `plum dev --parallel N` | Run tests across N parallel workers |',
253
- '| `plum start` | Start the full UI via Docker (`http://localhost:5173`) |',
275
+ '| `plum start` | Start the full UI via Docker |',
276
+ '| `plum stop` | Stop the server |',
254
277
  '| `plum create-step` | Interactively generate a new step definition |',
255
278
  '',
279
+ '---',
280
+ '',
256
281
  '## Configuration',
257
282
  '',
258
283
  '| File | Purpose |',
259
- '|---|---|',
260
- '| `.env` | Set `BASE_URL` and `IS_HEADLESS` |',
261
- '| `plum.plugins.json` | Add extra npm packages for your tests |',
284
+ '| --- | --- |',
285
+ '| `.env` | Set `BASE_URL` (your app) and `IS_HEADLESS` (`true`/`false`) |',
286
+ '| `plum.plugins.json` | Add extra npm packages your tests need |',
287
+ '',
288
+ '---',
262
289
  '',
263
290
  '## Test Structure',
264
291
  '',
265
292
  '```',
266
293
  'tests/',
267
- ' features/ — Gherkin .feature files',
294
+ ' features/ — Gherkin .feature files (write your scenarios here)',
268
295
  ' step_definitions/ — TypeScript step implementations',
269
296
  ' pages/ — Page Object Models',
270
- ' utils/ — Browser setup, hooks, helpers',
297
+ ' utils/ — Browser setup, hooks, shared helpers',
271
298
  '```',
272
299
  '',
273
- 'Tags are used to filter which tests to run:',
300
+ 'Each scenario needs a unique tag so you can run it by itself:',
274
301
  '',
275
302
  '```gherkin',
276
303
  '@suite-login',
277
304
  'Feature: Login',
278
305
  '',
279
306
  ' @test-login-1',
280
- ' Scenario: User can log in',
307
+ ' Scenario: User can log in with valid credentials',
281
308
  ' Given I am on the login page',
282
- ' ...',
309
+ ' When I enter valid credentials',
310
+ ' Then I should see the dashboard',
283
311
  '```',
284
312
  '',
285
313
  '```bash',
286
- 'plum dev @test-login-1 # single scenario',
287
- 'plum dev @suite-login # whole suite',
288
- '```'
314
+ 'plum dev @test-login-1 # run a single scenario',
315
+ 'plum dev @suite-login # run the whole suite',
316
+ '```',
317
+ '',
318
+ '---',
319
+ '',
320
+ '## Cucumber & Gherkin Resources',
321
+ '',
322
+ 'New to Cucumber? These links will get you up to speed quickly:',
323
+ '',
324
+ '- [Gherkin syntax reference](https://cucumber.io/docs/gherkin/reference/) — Feature files, Scenarios, Given/When/Then, tags, Scenario Outlines',
325
+ '- [Step definitions guide](https://cucumber.io/docs/cucumber/step-definitions/) — Connecting Gherkin steps to TypeScript code',
326
+ '- [Playwright docs](https://playwright.dev/docs/intro) — Browser automation API used inside page objects',
327
+ '- [Plum documentation](https://github.com/silverlunah/plum) — Full README and reference'
289
328
  ].join('\n');
290
329
  fs.writeFileSync(userReadmePath, readmeContent + '\n', 'utf8');
291
- console.log('✅ README.md created with command reference.\n');
330
+ console.log('✅ README.md created with quick-start guide.\n');
292
331
  } else {
293
332
  console.log('⚠️ README.md already exists. Skipping.\n');
294
333
  }
@@ -308,6 +347,18 @@ switch (command) {
308
347
  console.log('--------------------------------------\n');
309
348
  break;
310
349
 
350
+ case 'server':
351
+ if (subcommand === 'stop') {
352
+ console.log('--------------------------------------\n');
353
+ console.log('🛑 Stopping Plum server...');
354
+ execSync('docker compose down', { cwd: plumRoot, stdio: 'inherit' });
355
+ console.log('✅ Plum server stopped. Your data is preserved.\n');
356
+ console.log('--------------------------------------\n');
357
+ break;
358
+ }
359
+ // fall through to start for 'plum server start' or 'plum server'
360
+ // intentional fall-through
361
+
311
362
  case 'start':
312
363
  console.log('--------------------------------------\n');
313
364
 
@@ -434,6 +485,64 @@ switch (command) {
434
485
  console.log('--------------------------------------\n');
435
486
  break;
436
487
 
488
+ case 'node': {
489
+ if (subcommand === 'stop') {
490
+ console.log('--------------------------------------\n');
491
+ console.log('🛑 Stopping Plum node...');
492
+ execSync('docker compose -f docker-compose.node.yml down', {
493
+ cwd: plumRoot,
494
+ stdio: 'inherit'
495
+ });
496
+ console.log('✅ Plum node stopped.\n');
497
+ console.log('--------------------------------------\n');
498
+ break;
499
+ }
500
+
501
+ // 'plum node start' — parse --token and --primary flags
502
+ const nodeArgs = process.argv.slice(3);
503
+ const tokenIdx = nodeArgs.indexOf('--token');
504
+ const nodeToken = tokenIdx !== -1 ? nodeArgs[tokenIdx + 1] : process.env.NODE_TOKEN || '';
505
+ const primaryIdx = nodeArgs.indexOf('--primary');
506
+ const primaryUrl = primaryIdx !== -1 ? nodeArgs[primaryIdx + 1] : process.env.PRIMARY_URL || '';
507
+
508
+ console.log('--------------------------------------\n');
509
+ console.log('🚀 Starting Plum node (runner mode)...');
510
+ if (!nodeToken) {
511
+ console.log(
512
+ '⚠️ No --token provided. The node will accept requests without authentication.\n'
513
+ );
514
+ }
515
+
516
+ // Build override for node mode
517
+ const userReportsAbs = path.resolve(process.cwd(), 'reports').replace(/\\/g, '/');
518
+ const nodeOverridePath = path.join(plumRoot, 'docker-compose.node-override.yml');
519
+
520
+ const nodeOverride = [
521
+ 'services:',
522
+ ' backend:',
523
+ ' volumes:',
524
+ ` - "${userReportsAbs}:/app/reports"`,
525
+ ' environment:',
526
+ ` NODE_TOKEN: "${nodeToken}"`,
527
+ ` PRIMARY_URL: "${primaryUrl}"`,
528
+ ' PLUM_MODE: "node"'
529
+ ].join('\n');
530
+
531
+ fs.writeFileSync(nodeOverridePath, nodeOverride + '\n', 'utf8');
532
+
533
+ copyEnvFile();
534
+
535
+ execSync(
536
+ 'docker compose -f docker-compose.node.yml -f docker-compose.node-override.yml up --build',
537
+ {
538
+ cwd: plumRoot,
539
+ stdio: 'inherit'
540
+ }
541
+ );
542
+ console.log('--------------------------------------\n');
543
+ break;
544
+ }
545
+
437
546
  case 'create-step': {
438
547
  const createStepScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-step.mjs');
439
548
  execSync(`node "${createStepScript}"`, {
@@ -450,10 +559,14 @@ switch (command) {
450
559
  default:
451
560
  console.log('--------------------------------------\n');
452
561
  console.log('Usage: plum <command>\n');
453
- console.log(' init Set up a new Plum project');
454
- console.log(' start Start the full UI stack via Docker');
455
- console.log(' stop Stop Docker containers (data is preserved)');
456
- console.log(' dev Run tests locally without Docker');
457
- console.log(' create-step Interactively scaffold a new step definition');
562
+ console.log(' init Set up a new Plum project');
563
+ console.log(' server start Start the full UI stack via Docker (alias: plum start)');
564
+ console.log(' server stop Stop the server (alias: plum stop)');
565
+ console.log(' node start Start a runner node (no UI, receives remote jobs)');
566
+ console.log(' --token <secret> Auth token the primary must send');
567
+ console.log(' --primary <url> URL of the primary Plum server');
568
+ console.log(' node stop Stop the runner node');
569
+ console.log(' dev Run tests locally without Docker');
570
+ console.log(' create-step Interactively scaffold a new step definition');
458
571
  console.log('\n--------------------------------------\n');
459
572
  }
@@ -0,0 +1,59 @@
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
+ # Runner node — backend + postgres only, no frontend UI.
19
+ # Start with: plum node start --token <secret> --primary http://primary:3001
20
+
21
+ services:
22
+ postgres:
23
+ image: postgres:16-alpine
24
+ environment:
25
+ POSTGRES_DB: plum
26
+ POSTGRES_USER: plum
27
+ POSTGRES_PASSWORD: plum
28
+ volumes:
29
+ - node_postgres_data:/var/lib/postgresql/data
30
+ networks:
31
+ - node-network
32
+ healthcheck:
33
+ test: ['CMD-SHELL', 'pg_isready -U plum']
34
+ interval: 5s
35
+ timeout: 5s
36
+ retries: 10
37
+
38
+ backend:
39
+ build: ./backend
40
+ ports:
41
+ - '3001:3001'
42
+ environment:
43
+ DATABASE_URL: 'postgresql://plum:plum@postgres:5432/plum'
44
+ PLUM_MODE: 'node'
45
+ volumes:
46
+ - ./backend/reports:/app/reports
47
+ - ./backend/tests:/app/tests:rw
48
+ depends_on:
49
+ postgres:
50
+ condition: service_healthy
51
+ networks:
52
+ - node-network
53
+
54
+ networks:
55
+ node-network:
56
+ driver: bridge
57
+
58
+ volumes:
59
+ node_postgres_data:
@@ -38,6 +38,8 @@ services:
38
38
  - '3001:3001'
39
39
  environment:
40
40
  DATABASE_URL: 'postgresql://plum:plum@postgres:5432/plum'
41
+ extra_hosts:
42
+ - 'host.docker.internal:host-gateway'
41
43
  volumes:
42
44
  - ./backend/reports:/app/reports
43
45
  - ./backend/tests:/app/tests:rw
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plum-frontend",
3
3
  "private": true,
4
- "version": "1.2.1",
4
+ "version": "1.3.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "vite dev",
@@ -14,10 +14,7 @@
14
14
  "@sveltejs/adapter-node": "^5.5.4",
15
15
  "@sveltejs/kit": "^2.16.0",
16
16
  "@sveltejs/vite-plugin-svelte": "^5.0.0",
17
- "autoprefixer": "^10.4.20",
18
- "postcss": "^8.5.1",
19
17
  "svelte": "^5.0.0",
20
- "tailwindcss": "^3.4.17",
21
18
  "vite": "^6.0.0"
22
19
  },
23
20
  "dependencies": {