plum-e2e 1.3.4 → 1.3.5
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/README.md +41 -24
- package/backend/config/scripts/create-step.mjs +15 -14
- package/backend/lib/runnerProcess.js +2 -3
- package/backend/scripts/manage-runners.mjs +6 -17
- package/backend/services/cronService.js +16 -5
- package/backend/websockets/socketHandler.js +23 -5
- package/bin/plum.js +262 -220
- package/frontend/src/app.css +0 -16
- package/frontend/src/lib/constants.js +1 -2
- package/frontend/src/lib/stores/theme.js +0 -17
- package/frontend/src/lib/styles/global.css +0 -16
- package/frontend/src/lib/styles/reset.css +0 -16
- package/frontend/src/lib/styles/tokens.css +0 -16
- package/package.json +5 -3
package/README.md
CHANGED
|
@@ -282,11 +282,11 @@ plum run-test --parallel 4 @suite-login # run a suite in parallel
|
|
|
282
282
|
plum run-test --browser firefox # run in a specific browser
|
|
283
283
|
```
|
|
284
284
|
|
|
285
|
-
| Flag | Description
|
|
286
|
-
| ------------------ |
|
|
287
|
-
| `@tag` | Run only tests matching the tag
|
|
288
|
-
| `--parallel <n>` | Run across `n` parallel workers
|
|
289
|
-
| `--browser <name>` | `chromium` (default)
|
|
285
|
+
| Flag | Description |
|
|
286
|
+
| ------------------ | --------------------------------- |
|
|
287
|
+
| `@tag` | Run only tests matching the tag |
|
|
288
|
+
| `--parallel <n>` | Run across `n` parallel workers |
|
|
289
|
+
| `--browser <name>` | `chromium` (default) or `firefox` |
|
|
290
290
|
|
|
291
291
|
> `plum run-test` syncs your tests, installs dependencies, and runs Cucumber. No Docker needed.
|
|
292
292
|
|
|
@@ -296,6 +296,8 @@ plum run-test --browser firefox # run in a specific browser
|
|
|
296
296
|
|
|
297
297
|
Runners are additional machines that execute tests in parallel alongside the primary server, letting you distribute a large suite across many nodes. A node runs as a plain Node process — **no Docker required**.
|
|
298
298
|
|
|
299
|
+
Each node automatically installs **Chromium and Firefox** — no browser selection needed. When triggering a test run from the UI, you choose which browser to use at run time.
|
|
300
|
+
|
|
299
301
|
### Start a runner node
|
|
300
302
|
|
|
301
303
|
On the machine that will act as a runner, navigate to your Plum project and run:
|
|
@@ -304,7 +306,7 @@ On the machine that will act as a runner, navigate to your Plum project and run:
|
|
|
304
306
|
plum node start
|
|
305
307
|
```
|
|
306
308
|
|
|
307
|
-
Plum asks a few questions
|
|
309
|
+
Plum asks a few questions, **registers the node with your server automatically**, starts it in the background, then opens the runner management menu:
|
|
308
310
|
|
|
309
311
|
| Prompt | Default | What it sets |
|
|
310
312
|
| ------------------ | ------------------------- | -------------------------------------------------------------------- |
|
|
@@ -312,10 +314,11 @@ Plum asks a few questions and then **registers the node with your server automat
|
|
|
312
314
|
| **Local port** | `3001` | The port this node process listens on |
|
|
313
315
|
| **Advertised URL** | `http://<your-ip>:<port>` | The address the **server** uses to reach this node |
|
|
314
316
|
| **Runner name** | `node-<random>` | The name shown in the UI |
|
|
315
|
-
| **Browser** | `chromium` | Default browser for this node |
|
|
316
317
|
| **Auth token** | auto-generated | Shared secret; press Enter to keep the generated one |
|
|
317
318
|
|
|
318
|
-
|
|
319
|
+
The node starts as a **background daemon** — your terminal is free immediately. Logs go to `backend/logs/runner-<id>.log`. Settings are saved to `.plum-node.json`, so re-running reuses them and never creates a duplicate.
|
|
320
|
+
|
|
321
|
+
After setup, the **runner management menu** opens automatically. From there you can start, stop, restart, and ping any runner registered on the primary. Exit the menu any time — the node keeps running.
|
|
319
322
|
|
|
320
323
|
**Skip the prompts** with flags (handy for CI or scripted nodes):
|
|
321
324
|
|
|
@@ -330,7 +333,6 @@ plum node start --primary http://192.168.1.5:3001 --port 3001 --name ci-node-1
|
|
|
330
333
|
| `--port <n>` | Local HTTP port the node listens on (default `3001`). |
|
|
331
334
|
| `--token <secret>` | Auth token. Auto-generated and saved if omitted. |
|
|
332
335
|
| `--name <name>` | Runner name shown in the UI. |
|
|
333
|
-
| `--browser <name>` | `chromium` (default), `firefox`, or `webkit`. |
|
|
334
336
|
|
|
335
337
|
#### Nodes behind a domain or reverse proxy
|
|
336
338
|
|
|
@@ -342,6 +344,20 @@ plum node start --primary https://plum.example.com --url https://node1.example.c
|
|
|
342
344
|
|
|
343
345
|
The server reaches the node at `https://node1.example.com`; the proxy forwards to the node on port `3001`. The advertised `--url` must be reachable from the server.
|
|
344
346
|
|
|
347
|
+
### Manage runners
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
plum manage-runners
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Opens the interactive runner management menu at any time (reads the primary URL from your saved node config). You can also pass a different server:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
plum manage-runners --primary http://192.168.1.5:3001
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
> In development, the equivalent command is `npm run manage-runners` from the `backend/` directory.
|
|
360
|
+
|
|
345
361
|
### Change settings later
|
|
346
362
|
|
|
347
363
|
```bash
|
|
@@ -364,26 +380,27 @@ If you run `plum node start` without a reachable `--primary`, Plum prints the no
|
|
|
364
380
|
plum node stop
|
|
365
381
|
```
|
|
366
382
|
|
|
367
|
-
Stops the node started from the current folder.
|
|
383
|
+
Stops the node started from the current folder. You can also stop individual runners from the `plum manage-runners` menu.
|
|
368
384
|
|
|
369
385
|
---
|
|
370
386
|
|
|
371
387
|
## Command Reference
|
|
372
388
|
|
|
373
|
-
| Command | Description
|
|
374
|
-
| ----------------------------- |
|
|
375
|
-
| `plum init` | Initialize a new project in the current folder
|
|
376
|
-
| `plum server start` | Start the full UI stack via Docker, interactively (alias: `plum start`)
|
|
377
|
-
| `plum server reconfig` | Re-enter server settings (URL, ports) without starting
|
|
378
|
-
| `plum server stop` | Stop the server and preserve data (alias: `plum stop`)
|
|
379
|
-
| `plum run-test` | Run all tests locally without Docker
|
|
380
|
-
| `plum run-test @tag` | Run tests matching a tag
|
|
381
|
-
| `plum run-test --parallel N` | Run tests across N parallel workers
|
|
382
|
-
| `plum run-test --browser <b>` | Run in a specific browser (chromium
|
|
383
|
-
| `plum create-step` | Interactively scaffold a new step definition
|
|
384
|
-
| `plum node start` |
|
|
385
|
-
| `plum node reconfig` | Re-enter node settings + re-register, without starting
|
|
386
|
-
| `plum node stop` | Stop the runner node started from this folder
|
|
389
|
+
| Command | Description |
|
|
390
|
+
| ----------------------------- | ------------------------------------------------------------------------------ |
|
|
391
|
+
| `plum init` | Initialize a new project in the current folder |
|
|
392
|
+
| `plum server start` | Start the full UI stack via Docker, interactively (alias: `plum start`) |
|
|
393
|
+
| `plum server reconfig` | Re-enter server settings (URL, ports) without starting |
|
|
394
|
+
| `plum server stop` | Stop the server and preserve data (alias: `plum stop`) |
|
|
395
|
+
| `plum run-test` | Run all tests locally without Docker |
|
|
396
|
+
| `plum run-test @tag` | Run tests matching a tag |
|
|
397
|
+
| `plum run-test --parallel N` | Run tests across N parallel workers |
|
|
398
|
+
| `plum run-test --browser <b>` | Run in a specific browser (`chromium` or `firefox`) |
|
|
399
|
+
| `plum create-step` | Interactively scaffold a new step definition |
|
|
400
|
+
| `plum node start` | Configure, register, and start a runner node; opens the runner management menu |
|
|
401
|
+
| `plum node reconfig` | Re-enter node settings + re-register, without starting |
|
|
402
|
+
| `plum node stop` | Stop the runner node started from this folder |
|
|
403
|
+
| `plum manage-runners` | Open the interactive runner management menu |
|
|
387
404
|
|
|
388
405
|
---
|
|
389
406
|
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/*
|
|
2
|
-
This file is part of Plum.
|
|
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
|
+
*/
|
|
3
17
|
|
|
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
|
import * as clack from '@clack/prompts';
|
|
18
19
|
import pc from 'picocolors';
|
|
19
20
|
import fs from 'fs';
|
|
@@ -109,13 +109,12 @@ function statusOf(id, registry = loadRegistry()) {
|
|
|
109
109
|
* an interactive prompt running in the same terminal (the caller's menu would
|
|
110
110
|
* wedge after the install finishes).
|
|
111
111
|
*/
|
|
112
|
-
function prepareEnv(
|
|
112
|
+
function prepareEnv() {
|
|
113
113
|
const stdio = ['ignore', 'inherit', 'inherit'];
|
|
114
114
|
if (!fs.existsSync(path.join(BACKEND_DIR, 'node_modules'))) {
|
|
115
115
|
execSync('npm install', { cwd: BACKEND_DIR, stdio, shell: true });
|
|
116
116
|
}
|
|
117
|
-
|
|
118
|
-
execSync(`npx playwright install${target}`, { cwd: BACKEND_DIR, stdio, shell: true });
|
|
117
|
+
execSync('npx playwright install chromium firefox', { cwd: BACKEND_DIR, stdio, shell: true });
|
|
119
118
|
}
|
|
120
119
|
|
|
121
120
|
/**
|
|
@@ -103,10 +103,10 @@ function statusBadge(r) {
|
|
|
103
103
|
* npm/playwright progress is visible. A failure is surfaced but non-fatal — the
|
|
104
104
|
* operator can retry or fix it manually.
|
|
105
105
|
*/
|
|
106
|
-
function prepareNodeEnv(
|
|
107
|
-
clack.log.step(
|
|
106
|
+
function prepareNodeEnv() {
|
|
107
|
+
clack.log.step('Preparing node environment (deps + browsers)...');
|
|
108
108
|
try {
|
|
109
|
-
prepareEnv(
|
|
109
|
+
prepareEnv();
|
|
110
110
|
clack.log.success(pc.green('Environment ready.'));
|
|
111
111
|
return true;
|
|
112
112
|
} catch (e) {
|
|
@@ -152,7 +152,7 @@ async function runAction(r) {
|
|
|
152
152
|
const port = parsePort(r.url);
|
|
153
153
|
|
|
154
154
|
if (action === 'start') {
|
|
155
|
-
prepareNodeEnv(
|
|
155
|
+
prepareNodeEnv();
|
|
156
156
|
const entry = startNode({ id: r.id, port, token: r.token });
|
|
157
157
|
clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
|
|
158
158
|
} else if (action === 'stop') {
|
|
@@ -204,17 +204,6 @@ async function addRunner() {
|
|
|
204
204
|
});
|
|
205
205
|
if (cancelled(port)) return;
|
|
206
206
|
|
|
207
|
-
const browser = await clack.select({
|
|
208
|
-
message: 'Default browser',
|
|
209
|
-
options: [
|
|
210
|
-
{ value: 'chromium', label: 'Chrome' },
|
|
211
|
-
{ value: 'firefox', label: 'Firefox' },
|
|
212
|
-
{ value: 'webkit', label: 'WebKit' }
|
|
213
|
-
],
|
|
214
|
-
initialValue: 'chromium'
|
|
215
|
-
});
|
|
216
|
-
if (cancelled(browser)) return;
|
|
217
|
-
|
|
218
207
|
const defToken = process.env.NODE_TOKEN || generateToken();
|
|
219
208
|
const token = await clack.text({ message: 'Auth token', placeholder: defToken, defaultValue: defToken });
|
|
220
209
|
if (cancelled(token)) return;
|
|
@@ -227,7 +216,7 @@ async function addRunner() {
|
|
|
227
216
|
s.start(`Registering "${name}" with the primary...`);
|
|
228
217
|
let id;
|
|
229
218
|
try {
|
|
230
|
-
const res = await registerWithPrimary({ primary: API_URL, name, url, token, browser });
|
|
219
|
+
const res = await registerWithPrimary({ primary: API_URL, name, url, token, browser: 'chromium' });
|
|
231
220
|
id = res.id;
|
|
232
221
|
s.stop(res.reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}" (id ${id})`));
|
|
233
222
|
} catch (e) {
|
|
@@ -235,7 +224,7 @@ async function addRunner() {
|
|
|
235
224
|
return;
|
|
236
225
|
}
|
|
237
226
|
|
|
238
|
-
prepareNodeEnv(
|
|
227
|
+
prepareNodeEnv();
|
|
239
228
|
|
|
240
229
|
const startNow = await clack.confirm({ message: 'Start this runner now?' });
|
|
241
230
|
if (!cancelled(startNow) && startNow) {
|
|
@@ -88,9 +88,20 @@ function runSingleBuiltIn({ taskName, tags, workers, browser }) {
|
|
|
88
88
|
async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
|
|
89
89
|
const allIds = getTestIdsForTag(tags);
|
|
90
90
|
const chunks = chunkTests(allIds, runnerIds.length);
|
|
91
|
-
const laneInfos = await resolveLaneInfos(runnerIds);
|
|
92
91
|
|
|
93
|
-
|
|
92
|
+
// Surplus runners beyond the number of non-empty chunks would fall back to
|
|
93
|
+
// running the full tag expression, producing duplicate scenarios in the report.
|
|
94
|
+
const activeRunnerIds = runnerIds.slice(0, chunks.length);
|
|
95
|
+
|
|
96
|
+
if (activeRunnerIds.length === 0) {
|
|
97
|
+
console.log(`Task "${taskName}" — no tests found, skipping.`);
|
|
98
|
+
if (_io) _io.emit('cron-done', { taskName, code: 0 });
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const laneInfos = await resolveLaneInfos(activeRunnerIds);
|
|
103
|
+
|
|
104
|
+
const collectedReports = new Array(activeRunnerIds.length).fill(null);
|
|
94
105
|
let doneCount = 0;
|
|
95
106
|
let overallCode = 0;
|
|
96
107
|
|
|
@@ -99,7 +110,7 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
|
|
|
99
110
|
collectedReports[idx] = reportContent;
|
|
100
111
|
doneCount++;
|
|
101
112
|
|
|
102
|
-
if (doneCount ===
|
|
113
|
+
if (doneCount === activeRunnerIds.length) {
|
|
103
114
|
console.log(`Task "${taskName}" — all runners done (exit ${overallCode})`);
|
|
104
115
|
if (_io) _io.emit('cron-done', { taskName, code: overallCode });
|
|
105
116
|
|
|
@@ -116,9 +127,9 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
|
|
|
116
127
|
}
|
|
117
128
|
}
|
|
118
129
|
|
|
119
|
-
for (let i = 0; i <
|
|
130
|
+
for (let i = 0; i < activeRunnerIds.length; i++) {
|
|
120
131
|
const lane = laneInfos[i];
|
|
121
|
-
const chunkTag =
|
|
132
|
+
const chunkTag = buildTagExpression(chunks[i]);
|
|
122
133
|
|
|
123
134
|
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
124
135
|
const env = {
|
|
@@ -118,8 +118,26 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
118
118
|
const allIds = getTestIdsForTag(tag);
|
|
119
119
|
const chunks = chunkTests(allIds, runnerIds.length);
|
|
120
120
|
|
|
121
|
+
// Surplus runners beyond the number of non-empty chunks would fall back to
|
|
122
|
+
// running the full tag expression, producing duplicate scenarios in the report.
|
|
123
|
+
const activeRunnerIds = runnerIds.slice(0, chunks.length);
|
|
124
|
+
|
|
125
|
+
if (activeRunnerIds.length === 0) {
|
|
126
|
+
socket.emit('log', '\nNo tests found matching the selected tag.\n');
|
|
127
|
+
socket.emit('done', 0);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (activeRunnerIds.length < runnerIds.length) {
|
|
132
|
+
const skipped = runnerIds.length - activeRunnerIds.length;
|
|
133
|
+
socket.emit(
|
|
134
|
+
'log',
|
|
135
|
+
`[INFO] ${skipped} runner(s) not used — fewer tests than runners selected.\n`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
121
139
|
const laneInfos = await Promise.all(
|
|
122
|
-
|
|
140
|
+
activeRunnerIds.map(async (id) => {
|
|
123
141
|
if (id === BUILT_IN_RUNNER_ID) return { id, name: 'Built-in', dbId: null };
|
|
124
142
|
const r = await runnerService.getById(id);
|
|
125
143
|
return { id, name: r?.name ?? id, dbId: r?.id ?? null };
|
|
@@ -128,10 +146,10 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
128
146
|
|
|
129
147
|
socket.emit(
|
|
130
148
|
'runner-lanes-init',
|
|
131
|
-
laneInfos.map((l, i) => ({ id: l.id, name: l.name, testCount:
|
|
149
|
+
laneInfos.map((l, i) => ({ id: l.id, name: l.name, testCount: chunks[i].length }))
|
|
132
150
|
);
|
|
133
151
|
|
|
134
|
-
const total =
|
|
152
|
+
const total = activeRunnerIds.length;
|
|
135
153
|
const collectedReports = new Array(total).fill(null);
|
|
136
154
|
let doneCount = 0;
|
|
137
155
|
let overallCode = 0;
|
|
@@ -168,9 +186,9 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
|
|
|
168
186
|
}
|
|
169
187
|
}
|
|
170
188
|
|
|
171
|
-
for (let i = 0; i <
|
|
189
|
+
for (let i = 0; i < activeRunnerIds.length; i++) {
|
|
172
190
|
const lane = laneInfos[i];
|
|
173
|
-
const chunkTag =
|
|
191
|
+
const chunkTag = buildTagExpression(chunks[i]);
|
|
174
192
|
|
|
175
193
|
if (lane.id === BUILT_IN_RUNNER_ID) {
|
|
176
194
|
const env = {
|
package/bin/plum.js
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
import { execSync, spawn } from 'child_process';
|
|
20
20
|
import fs from 'fs';
|
|
21
21
|
import path from 'path';
|
|
22
|
-
import readline from 'readline';
|
|
23
22
|
import { fileURLToPath } from 'url';
|
|
24
23
|
import { createRequire } from 'module';
|
|
25
24
|
import fse from 'fs-extra';
|
|
25
|
+
import * as clack from '@clack/prompts';
|
|
26
|
+
import pc from 'picocolors';
|
|
26
27
|
|
|
27
28
|
const __filename = fileURLToPath(import.meta.url);
|
|
28
29
|
const __dirname = path.dirname(__filename);
|
|
@@ -42,28 +43,25 @@ const backendEnvPath = path.join(plumRoot, 'backend', '.env');
|
|
|
42
43
|
function createEnvFile() {
|
|
43
44
|
const envFilePath = path.join(process.cwd(), '.env');
|
|
44
45
|
|
|
45
|
-
// Check if .env file already exists
|
|
46
46
|
if (fs.existsSync(envFilePath)) {
|
|
47
47
|
copyEnvFile();
|
|
48
|
-
|
|
49
|
-
return;
|
|
48
|
+
clack.log.warn('.env already exists — synced to backend.');
|
|
49
|
+
return;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
// Default content for .env file
|
|
53
52
|
const envContent = `BASE_URL=https://www.saucedemo.com/v1/
|
|
54
53
|
IS_HEADLESS=false
|
|
55
54
|
`;
|
|
56
55
|
|
|
57
|
-
// Write the content to the .env file
|
|
58
56
|
fs.writeFileSync(envFilePath, envContent, 'utf8');
|
|
59
|
-
|
|
57
|
+
clack.log.success('.env created with default values.');
|
|
60
58
|
}
|
|
61
59
|
|
|
62
60
|
// Scaffold plum.plugins.json if it doesn't exist yet
|
|
63
61
|
function scaffoldPluginsFile() {
|
|
64
62
|
const pluginsPath = path.join(process.cwd(), 'plum.plugins.json');
|
|
65
63
|
if (fs.existsSync(pluginsPath)) {
|
|
66
|
-
|
|
64
|
+
clack.log.warn('plum.plugins.json already exists — skipping.');
|
|
67
65
|
return;
|
|
68
66
|
}
|
|
69
67
|
const content = {
|
|
@@ -73,7 +71,7 @@ function scaffoldPluginsFile() {
|
|
|
73
71
|
dependencies: {}
|
|
74
72
|
};
|
|
75
73
|
fs.writeFileSync(pluginsPath, JSON.stringify(content, null, 2) + '\n', 'utf8');
|
|
76
|
-
|
|
74
|
+
clack.log.success('plum.plugins.json created.');
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
// Install user plugins listed in plum.plugins.json into the backend
|
|
@@ -108,19 +106,19 @@ function ensureGitignore() {
|
|
|
108
106
|
|
|
109
107
|
if (!fs.existsSync(gitignorePath)) {
|
|
110
108
|
fs.writeFileSync(gitignorePath, plumBlock.trimStart(), 'utf8');
|
|
111
|
-
|
|
109
|
+
clack.log.success('.gitignore created with Plum entries.');
|
|
112
110
|
return;
|
|
113
111
|
}
|
|
114
112
|
|
|
115
113
|
const existing = fs.readFileSync(gitignorePath, 'utf8');
|
|
116
114
|
const missing = plumEntries.filter((e) => !existing.includes(e));
|
|
117
115
|
if (missing.length === 0) {
|
|
118
|
-
|
|
116
|
+
clack.log.warn('.gitignore already contains Plum entries — skipping.');
|
|
119
117
|
return;
|
|
120
118
|
}
|
|
121
119
|
|
|
122
120
|
fs.appendFileSync(gitignorePath, `\n# Plum (auto-generated)\n${missing.join('\n')}\n`);
|
|
123
|
-
|
|
121
|
+
clack.log.success('.gitignore updated with Plum entries.');
|
|
124
122
|
}
|
|
125
123
|
|
|
126
124
|
// Function to copy .env file from root to backend
|
|
@@ -128,18 +126,18 @@ function copyEnvFile() {
|
|
|
128
126
|
try {
|
|
129
127
|
if (fs.existsSync(rootEnvPath)) {
|
|
130
128
|
fse.copySync(rootEnvPath, backendEnvPath);
|
|
131
|
-
console.log('📦 .env file copied to the backend folder.\n');
|
|
132
129
|
} else {
|
|
133
|
-
|
|
130
|
+
clack.log.warn('.env not found in project root — skipping backend sync.');
|
|
134
131
|
}
|
|
135
132
|
} catch (err) {
|
|
136
|
-
|
|
133
|
+
clack.log.error(`Error copying .env: ${err.message}`);
|
|
137
134
|
}
|
|
138
135
|
}
|
|
139
136
|
|
|
140
137
|
const backendLib = path.join(plumRoot, 'backend', 'lib');
|
|
141
138
|
const serverConfigLib = () => require(path.join(backendLib, 'serverConfig.js'));
|
|
142
139
|
const nodeRegisterLib = () => require(path.join(backendLib, 'nodeRegister.js'));
|
|
140
|
+
const runnerProcessLib = () => require(path.join(backendLib, 'runnerProcess.js'));
|
|
143
141
|
|
|
144
142
|
/* -----------------------------------------------------
|
|
145
143
|
* Interactive prompts
|
|
@@ -152,64 +150,12 @@ const getFlag = (args, name) => {
|
|
|
152
150
|
};
|
|
153
151
|
const anyFlags = (args, names) => names.some((n) => args.includes(n));
|
|
154
152
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
* lines as they arrive and hand them out one question at a time.
|
|
159
|
-
*/
|
|
160
|
-
function createPrompter() {
|
|
161
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
162
|
-
const queue = [];
|
|
163
|
-
let waiting = null;
|
|
164
|
-
let closed = false;
|
|
165
|
-
rl.on('line', (line) => {
|
|
166
|
-
if (waiting) {
|
|
167
|
-
const r = waiting;
|
|
168
|
-
waiting = null;
|
|
169
|
-
r(line);
|
|
170
|
-
} else queue.push(line);
|
|
171
|
-
});
|
|
172
|
-
rl.on('close', () => {
|
|
173
|
-
closed = true;
|
|
174
|
-
if (waiting) {
|
|
175
|
-
const r = waiting;
|
|
176
|
-
waiting = null;
|
|
177
|
-
r(null);
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
const nextLine = () =>
|
|
181
|
-
new Promise((resolve) => {
|
|
182
|
-
if (queue.length) resolve(queue.shift());
|
|
183
|
-
else if (closed) resolve(null);
|
|
184
|
-
else waiting = resolve;
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
const ask = async (q, def) => {
|
|
188
|
-
process.stdout.write(` ${q}${def ? ` (${def})` : ''}: `);
|
|
189
|
-
const line = await nextLine();
|
|
190
|
-
const a = (line ?? '').trim();
|
|
191
|
-
return a || def || '';
|
|
192
|
-
};
|
|
193
|
-
const askYesNo = async (q, def) => {
|
|
194
|
-
process.stdout.write(` ${q} (${def ? 'Y/n' : 'y/N'}): `);
|
|
195
|
-
const a = (((await nextLine()) ?? '') + '').trim().toLowerCase();
|
|
196
|
-
if (!a) return def;
|
|
197
|
-
return a === 'y' || a === 'yes';
|
|
198
|
-
};
|
|
199
|
-
const askChoice = async (q, opts, def) => {
|
|
200
|
-
console.log(` ${q}`);
|
|
201
|
-
opts.forEach((o, i) => console.log(` ${i + 1}) ${o}${o === def ? ' (default)' : ''}`));
|
|
202
|
-
process.stdout.write(' > ');
|
|
203
|
-
const a = (((await nextLine()) ?? '') + '').trim();
|
|
204
|
-
if (!a) return def;
|
|
205
|
-
const n = Number(a);
|
|
206
|
-
if (Number.isInteger(n) && n >= 1 && n <= opts.length) return opts[n - 1];
|
|
207
|
-
return opts.includes(a) ? a : def;
|
|
208
|
-
};
|
|
209
|
-
return { ask, askYesNo, askChoice, close: () => rl.close() };
|
|
153
|
+
function cancelAndExit() {
|
|
154
|
+
clack.cancel('Cancelled.');
|
|
155
|
+
process.exit(0);
|
|
210
156
|
}
|
|
211
157
|
|
|
212
|
-
const VALID_BROWSERS = ['chromium', 'firefox'
|
|
158
|
+
const VALID_BROWSERS = ['chromium', 'firefox'];
|
|
213
159
|
|
|
214
160
|
/* -----------------------------------------------------
|
|
215
161
|
* Server flow
|
|
@@ -226,10 +172,10 @@ function mergeUserPlugins() {
|
|
|
226
172
|
if (Object.keys(pluginDeps).length > 0) {
|
|
227
173
|
backendPkg.dependencies = { ...backendPkg.dependencies, ...pluginDeps };
|
|
228
174
|
fs.writeFileSync(backendPkgPath, JSON.stringify(backendPkg, null, '\t') + '\n', 'utf8');
|
|
229
|
-
|
|
175
|
+
clack.log.info(`Merged plugins into backend: ${Object.keys(pluginDeps).join(', ')}`);
|
|
230
176
|
}
|
|
231
177
|
} catch {
|
|
232
|
-
|
|
178
|
+
clack.log.warn('Could not read plum.plugins.json. Skipping plugin merge.');
|
|
233
179
|
}
|
|
234
180
|
}
|
|
235
181
|
|
|
@@ -262,19 +208,44 @@ async function configureServer({ force }) {
|
|
|
262
208
|
const interactive = force || (interactiveAllowed() && !hasFlags);
|
|
263
209
|
|
|
264
210
|
if (interactive) {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
cfg.baseUrl
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
211
|
+
const baseUrl = await clack.text({
|
|
212
|
+
message: 'App URL to test (BASE_URL)',
|
|
213
|
+
placeholder: cfg.baseUrl,
|
|
214
|
+
defaultValue: cfg.baseUrl
|
|
215
|
+
});
|
|
216
|
+
if (clack.isCancel(baseUrl)) cancelAndExit();
|
|
217
|
+
cfg.baseUrl = baseUrl || cfg.baseUrl;
|
|
218
|
+
|
|
219
|
+
const headless = await clack.confirm({
|
|
220
|
+
message: 'Run browsers headless?',
|
|
221
|
+
initialValue: cfg.headless
|
|
222
|
+
});
|
|
223
|
+
if (clack.isCancel(headless)) cancelAndExit();
|
|
224
|
+
cfg.headless = headless;
|
|
225
|
+
|
|
226
|
+
const backendPort = await clack.text({
|
|
227
|
+
message: 'Backend port',
|
|
228
|
+
placeholder: String(cfg.backendPort),
|
|
229
|
+
defaultValue: String(cfg.backendPort)
|
|
230
|
+
});
|
|
231
|
+
if (clack.isCancel(backendPort)) cancelAndExit();
|
|
232
|
+
cfg.backendPort = backendPort || cfg.backendPort;
|
|
233
|
+
|
|
234
|
+
const frontendPort = await clack.text({
|
|
235
|
+
message: 'Frontend (UI) port',
|
|
236
|
+
placeholder: String(cfg.frontendPort),
|
|
237
|
+
defaultValue: String(cfg.frontendPort)
|
|
238
|
+
});
|
|
239
|
+
if (clack.isCancel(frontendPort)) cancelAndExit();
|
|
240
|
+
cfg.frontendPort = frontendPort || cfg.frontendPort;
|
|
241
|
+
|
|
242
|
+
const primaryPublicUrl = await clack.text({
|
|
243
|
+
message: 'Primary public URL (share with node operators)',
|
|
244
|
+
placeholder: cfg.primaryPublicUrl,
|
|
245
|
+
defaultValue: cfg.primaryPublicUrl
|
|
246
|
+
});
|
|
247
|
+
if (clack.isCancel(primaryPublicUrl)) cancelAndExit();
|
|
248
|
+
cfg.primaryPublicUrl = primaryPublicUrl || cfg.primaryPublicUrl;
|
|
278
249
|
}
|
|
279
250
|
|
|
280
251
|
saveServerConfig(cwd, cfg);
|
|
@@ -299,29 +270,27 @@ function applyServerConfig(cfg) {
|
|
|
299
270
|
}),
|
|
300
271
|
'utf8'
|
|
301
272
|
);
|
|
302
|
-
|
|
273
|
+
clack.log.success('docker-compose.override.yml written');
|
|
303
274
|
}
|
|
304
275
|
|
|
305
276
|
async function serverStart() {
|
|
306
|
-
|
|
307
|
-
console.log('🚀 Starting Plum...\n');
|
|
277
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
|
|
308
278
|
const cfg = await configureServer({ force: false });
|
|
309
279
|
applyServerConfig(cfg);
|
|
310
|
-
|
|
311
|
-
|
|
280
|
+
clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
|
|
281
|
+
clack.log.info(`Nodes register against: ${pc.dim(cfg.primaryPublicUrl)}`);
|
|
312
282
|
execSync('docker compose up --build', { cwd: plumRoot, stdio: 'inherit' });
|
|
313
|
-
|
|
283
|
+
clack.outro(pc.dim('Plum server stopped.'));
|
|
314
284
|
}
|
|
315
285
|
|
|
316
286
|
async function serverReconfig() {
|
|
317
|
-
|
|
318
|
-
console.log('⚙️ Reconfiguring Plum server...\n');
|
|
287
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Reconfigure Server ')));
|
|
319
288
|
const cfg = await configureServer({ force: true });
|
|
320
289
|
applyServerConfig(cfg);
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
290
|
+
clack.log.success("Saved. Run 'plum server start' to apply.");
|
|
291
|
+
clack.outro(
|
|
292
|
+
`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)} · Nodes: ${pc.dim(cfg.primaryPublicUrl)}`
|
|
293
|
+
);
|
|
325
294
|
}
|
|
326
295
|
|
|
327
296
|
/* -----------------------------------------------------
|
|
@@ -353,27 +322,52 @@ async function configureNode({ force }) {
|
|
|
353
322
|
const interactive = force || (interactiveAllowed() && !hasFlags);
|
|
354
323
|
|
|
355
324
|
if (interactive) {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
primary
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
325
|
+
const primaryVal = await clack.text({
|
|
326
|
+
message: 'Primary server URL',
|
|
327
|
+
placeholder: primary || 'http://localhost:3001',
|
|
328
|
+
defaultValue: primary
|
|
329
|
+
});
|
|
330
|
+
if (clack.isCancel(primaryVal)) cancelAndExit();
|
|
331
|
+
primary = primaryVal || primary;
|
|
332
|
+
|
|
333
|
+
const portVal = await clack.text({
|
|
334
|
+
message: 'Local port this node listens on',
|
|
335
|
+
placeholder: port,
|
|
336
|
+
defaultValue: port
|
|
337
|
+
});
|
|
338
|
+
if (clack.isCancel(portVal)) cancelAndExit();
|
|
339
|
+
port = portVal || port;
|
|
340
|
+
|
|
341
|
+
const defaultUrl = url || `http://${detectLanIp()}:${port}`;
|
|
342
|
+
const urlVal = await clack.text({
|
|
343
|
+
message: 'URL the primary calls back (advertised)',
|
|
344
|
+
placeholder: defaultUrl,
|
|
345
|
+
defaultValue: defaultUrl
|
|
346
|
+
});
|
|
347
|
+
if (clack.isCancel(urlVal)) cancelAndExit();
|
|
348
|
+
url = urlVal || defaultUrl;
|
|
349
|
+
|
|
350
|
+
const nameVal = await clack.text({
|
|
351
|
+
message: 'Runner name',
|
|
352
|
+
placeholder: name,
|
|
353
|
+
defaultValue: name
|
|
354
|
+
});
|
|
355
|
+
if (clack.isCancel(nameVal)) cancelAndExit();
|
|
356
|
+
name = nameVal || name;
|
|
357
|
+
|
|
358
|
+
const tokenVal = await clack.text({
|
|
359
|
+
message: 'Auth token (Enter to keep)',
|
|
360
|
+
placeholder: token,
|
|
361
|
+
defaultValue: token
|
|
362
|
+
});
|
|
363
|
+
if (clack.isCancel(tokenVal)) cancelAndExit();
|
|
364
|
+
token = tokenVal || token;
|
|
371
365
|
}
|
|
372
366
|
|
|
373
367
|
if (!url) url = `http://${detectLanIp()}:${port}`;
|
|
374
368
|
|
|
375
369
|
if (!VALID_BROWSERS.includes(browser)) {
|
|
376
|
-
|
|
370
|
+
clack.log.error(`Invalid browser "${browser}". Choose one of: ${VALID_BROWSERS.join(', ')}`);
|
|
377
371
|
process.exit(1);
|
|
378
372
|
}
|
|
379
373
|
|
|
@@ -393,36 +387,36 @@ async function configureNode({ force }) {
|
|
|
393
387
|
async function registerNode({ primary, name, url, token, browser, port }) {
|
|
394
388
|
const { registerWithPrimary, loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
395
389
|
let registeredId = null;
|
|
390
|
+
|
|
396
391
|
if (primary) {
|
|
397
|
-
|
|
392
|
+
const s = clack.spinner();
|
|
393
|
+
s.start(`Registering with primary at ${primary}...`);
|
|
398
394
|
try {
|
|
399
395
|
const { id, reused } = await registerWithPrimary({ primary, name, url, token, browser });
|
|
400
396
|
registeredId = id;
|
|
401
|
-
|
|
397
|
+
s.stop(pc.green(reused ? '✓ Reusing existing runner on primary' : '✓ Registered on primary'));
|
|
402
398
|
} catch (e) {
|
|
403
|
-
|
|
404
|
-
|
|
399
|
+
s.stop(pc.yellow(`Could not register with primary: ${e.message}`));
|
|
400
|
+
clack.log.warn('Add this runner manually using the details below.');
|
|
405
401
|
}
|
|
406
402
|
} else {
|
|
407
|
-
|
|
403
|
+
clack.log.info('No primary set — add this runner manually on your Plum server.');
|
|
408
404
|
}
|
|
409
405
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
:
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
`ℹ️ The url above must be reachable from the primary. The local port (${port}) is only`
|
|
406
|
+
clack.note(
|
|
407
|
+
[
|
|
408
|
+
registeredId ? `id: ${registeredId}` : 'id: (assigned when added on the server)',
|
|
409
|
+
`name: ${name}`,
|
|
410
|
+
`url: ${url}`,
|
|
411
|
+
`token: ${token}`,
|
|
412
|
+
`browser: ${browser}`
|
|
413
|
+
].join('\n'),
|
|
414
|
+
'Runner details'
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
clack.log.info(
|
|
418
|
+
`The url above must be reachable from the primary. The local port (${port}) is only what this node listens on — forward your proxy/domain to it.`
|
|
424
419
|
);
|
|
425
|
-
console.log(' what this node listens on — forward your proxy/domain to it.\n');
|
|
426
420
|
|
|
427
421
|
const cwd = process.cwd();
|
|
428
422
|
saveNodeConfig(cwd, {
|
|
@@ -440,45 +434,56 @@ async function registerNode({ primary, name, url, token, browser, port }) {
|
|
|
440
434
|
|
|
441
435
|
async function nodeStart({ reconfig }) {
|
|
442
436
|
const backendDir = path.join(plumRoot, 'backend');
|
|
443
|
-
|
|
444
|
-
console.log('🚀 Setting up Plum node (runner mode)...\n');
|
|
437
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Runner ')));
|
|
445
438
|
|
|
446
439
|
const cfg = await configureNode({ force: reconfig });
|
|
447
|
-
await registerNode(cfg);
|
|
448
|
-
|
|
449
|
-
// backend/node_modules is not published — install deps before launching.
|
|
450
|
-
console.log('Running `npm install`...');
|
|
451
|
-
execSync('npm install', { cwd: backendDir, stdio: 'inherit', shell: true });
|
|
452
|
-
console.log('Running `npx playwright install`...');
|
|
453
|
-
execSync('npx playwright install', { cwd: backendDir, stdio: 'inherit', shell: true });
|
|
440
|
+
const registeredId = await registerNode(cfg);
|
|
454
441
|
|
|
455
|
-
|
|
456
|
-
console.log(`🟣 Node "${cfg.name}" running on port ${cfg.port} (Ctrl+C to stop)\n`);
|
|
442
|
+
const { prepareEnv, startNode: startNodeProc } = runnerProcessLib();
|
|
457
443
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
444
|
+
clack.log.step('Preparing environment (deps + browsers)...');
|
|
445
|
+
try {
|
|
446
|
+
prepareEnv();
|
|
447
|
+
clack.log.success('Environment ready.');
|
|
448
|
+
} catch (e) {
|
|
449
|
+
clack.log.warn(`Environment prep failed: ${e.message}`);
|
|
450
|
+
}
|
|
465
451
|
|
|
466
|
-
|
|
452
|
+
if (registeredId) {
|
|
453
|
+
try {
|
|
454
|
+
const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
|
|
455
|
+
clack.log.success(
|
|
456
|
+
pc.green(
|
|
457
|
+
`Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at backend/logs/runner-${registeredId}.log`
|
|
458
|
+
)
|
|
459
|
+
);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
clack.log.warn(`Could not start runner process: ${e.message}`);
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
clack.log.info('Runner not registered on primary — use the menu below to add and start it.');
|
|
465
|
+
}
|
|
467
466
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
saveNodeConfig(process.cwd(), { ...c, pid: null });
|
|
471
|
-
process.exit(code ?? 0);
|
|
472
|
-
});
|
|
467
|
+
await openManageRunnersMenu(cfg.primary);
|
|
468
|
+
clack.outro(`Manage runners anytime: ${pc.cyan('plum manage-runners')}`);
|
|
473
469
|
}
|
|
474
470
|
|
|
475
471
|
async function nodeReconfig() {
|
|
476
|
-
|
|
477
|
-
console.log('⚙️ Reconfiguring Plum node...\n');
|
|
472
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Reconfigure Node ')));
|
|
478
473
|
const cfg = await configureNode({ force: true });
|
|
479
474
|
await registerNode(cfg);
|
|
480
|
-
|
|
481
|
-
|
|
475
|
+
clack.log.success("Saved. Run 'plum node start' to launch this node.");
|
|
476
|
+
clack.outro(pc.dim('Done.'));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function openManageRunnersMenu(primaryUrl) {
|
|
480
|
+
const manageScript = path.join(plumRoot, 'backend', 'scripts', 'manage-runners.mjs');
|
|
481
|
+
const apiUrl = primaryUrl || 'http://localhost:3001';
|
|
482
|
+
const menu = spawn(process.execPath, [manageScript], {
|
|
483
|
+
stdio: 'inherit',
|
|
484
|
+
env: { ...process.env, PLUM_API_URL: apiUrl }
|
|
485
|
+
});
|
|
486
|
+
await new Promise((resolve) => menu.on('exit', resolve));
|
|
482
487
|
}
|
|
483
488
|
|
|
484
489
|
/* -----------------------------------------------------
|
|
@@ -488,28 +493,22 @@ async function nodeReconfig() {
|
|
|
488
493
|
* "plum <command>" to run the desired command.
|
|
489
494
|
* ------------------------------------------------------ */
|
|
490
495
|
switch (command) {
|
|
491
|
-
case 'init':
|
|
492
|
-
|
|
493
|
-
console.log('🟣 Preparing Plum...\n');
|
|
496
|
+
case 'init': {
|
|
497
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Init ')));
|
|
494
498
|
|
|
499
|
+
// Test scaffold
|
|
495
500
|
if (fs.existsSync(userTestsPath)) {
|
|
496
|
-
|
|
501
|
+
clack.log.warn('`tests/` already exists — skipping scaffold.');
|
|
497
502
|
} else {
|
|
498
|
-
console.log('📦 Creating test scaffold...\n');
|
|
499
503
|
fse.copySync(scaffoldTestsPath, userTestsPath);
|
|
500
|
-
|
|
504
|
+
clack.log.success('`tests/` created with example files.');
|
|
501
505
|
}
|
|
502
506
|
|
|
503
|
-
// Create .env file with default values
|
|
504
507
|
createEnvFile();
|
|
505
|
-
|
|
506
|
-
// Create or update .gitignore with Plum-generated paths
|
|
507
508
|
ensureGitignore();
|
|
508
|
-
|
|
509
|
-
// Scaffold plum.plugins.json for user-managed dependencies
|
|
510
509
|
scaffoldPluginsFile();
|
|
511
510
|
|
|
512
|
-
//
|
|
511
|
+
// .vscode/settings.json
|
|
513
512
|
{
|
|
514
513
|
const vscodeSettingsPath = path.join(process.cwd(), '.vscode', 'settings.json');
|
|
515
514
|
if (!fs.existsSync(vscodeSettingsPath)) {
|
|
@@ -526,30 +525,30 @@ switch (command) {
|
|
|
526
525
|
) + '\n',
|
|
527
526
|
'utf8'
|
|
528
527
|
);
|
|
529
|
-
|
|
528
|
+
clack.log.success('.vscode/settings.json created for Cucumber extension.');
|
|
530
529
|
} else {
|
|
531
|
-
|
|
530
|
+
clack.log.warn('.vscode/settings.json already exists — skipping.');
|
|
532
531
|
}
|
|
533
532
|
|
|
534
|
-
//
|
|
533
|
+
// VS Code Cucumber extension
|
|
535
534
|
try {
|
|
536
535
|
execSync('code --version', { stdio: 'ignore' });
|
|
537
536
|
try {
|
|
538
|
-
execSync('code --install-extension cucumberopen.cucumber-official', {
|
|
539
|
-
|
|
537
|
+
execSync('code --install-extension cucumberopen.cucumber-official', {
|
|
538
|
+
stdio: 'ignore'
|
|
539
|
+
});
|
|
540
|
+
clack.log.success('Cucumber VS Code extension installed.');
|
|
540
541
|
} catch {
|
|
541
|
-
|
|
542
|
-
'
|
|
542
|
+
clack.log.warn(
|
|
543
|
+
'Could not install VS Code extension automatically.\n Install manually: cucumberopen.cucumber-official'
|
|
543
544
|
);
|
|
544
545
|
}
|
|
545
546
|
} catch {
|
|
546
|
-
|
|
547
|
-
'ℹ️ Install the Cucumber VS Code extension manually: cucumberopen.cucumber-official\n'
|
|
548
|
-
);
|
|
547
|
+
clack.log.info('Install the Cucumber extension manually: cucumberopen.cucumber-official');
|
|
549
548
|
}
|
|
550
549
|
}
|
|
551
550
|
|
|
552
|
-
//
|
|
551
|
+
// tsconfig.json
|
|
553
552
|
{
|
|
554
553
|
const tsconfigPath = path.join(process.cwd(), 'tsconfig.json');
|
|
555
554
|
if (!fs.existsSync(tsconfigPath)) {
|
|
@@ -576,13 +575,13 @@ switch (command) {
|
|
|
576
575
|
include: ['tests/**/*.ts']
|
|
577
576
|
};
|
|
578
577
|
fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n', 'utf8');
|
|
579
|
-
|
|
578
|
+
clack.log.success('tsconfig.json created for IDE type resolution.');
|
|
580
579
|
} else {
|
|
581
|
-
|
|
580
|
+
clack.log.warn('tsconfig.json already exists — skipping.');
|
|
582
581
|
}
|
|
583
582
|
}
|
|
584
583
|
|
|
585
|
-
//
|
|
584
|
+
// README.md
|
|
586
585
|
{
|
|
587
586
|
const userReadmePath = path.join(process.cwd(), 'README.md');
|
|
588
587
|
if (!fs.existsSync(userReadmePath)) {
|
|
@@ -619,7 +618,7 @@ switch (command) {
|
|
|
619
618
|
'| `plum run-test` | Run all tests locally |',
|
|
620
619
|
'| `plum run-test @tag` | Run tests matching a tag |',
|
|
621
620
|
'| `plum run-test --parallel N` | Run tests across N parallel workers |',
|
|
622
|
-
'| `plum run-test --browser firefox` | Run in a specific browser (chromium/firefox
|
|
621
|
+
'| `plum run-test --browser firefox` | Run in a specific browser (chromium/firefox) |',
|
|
623
622
|
'| `plum start` | Start the full UI via Docker (interactive setup) |',
|
|
624
623
|
'| `plum server reconfig` | Change server URL/ports without starting |',
|
|
625
624
|
'| `plum stop` | Stop the server |',
|
|
@@ -678,25 +677,37 @@ switch (command) {
|
|
|
678
677
|
'- [Plum documentation](https://github.com/silverlunah/plum) — Full README and reference'
|
|
679
678
|
].join('\n');
|
|
680
679
|
fs.writeFileSync(userReadmePath, readmeContent + '\n', 'utf8');
|
|
681
|
-
|
|
680
|
+
clack.log.success('README.md created.');
|
|
682
681
|
} else {
|
|
683
|
-
|
|
682
|
+
clack.log.warn('README.md already exists — skipping.');
|
|
684
683
|
}
|
|
685
684
|
}
|
|
686
685
|
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
686
|
+
// Install dependencies
|
|
687
|
+
clack.log.step('Installing dependencies (npm run init)...');
|
|
688
|
+
execSync('npm run init', { cwd: plumRoot, stdio: 'inherit' });
|
|
689
|
+
|
|
690
|
+
clack.note(
|
|
691
|
+
[
|
|
692
|
+
`Tests scaffold ${pc.dim('→')} ${pc.cyan('tests/')}`,
|
|
693
|
+
`Extra packages ${pc.dim('→')} ${pc.cyan('plum.plugins.json')}`,
|
|
694
|
+
`App URL config ${pc.dim('→')} ${pc.cyan('.env')}`,
|
|
695
|
+
'',
|
|
696
|
+
`${pc.bold('Run tests locally')}`,
|
|
697
|
+
` ${pc.cyan('plum run-test')} run all tests`,
|
|
698
|
+
` ${pc.cyan('plum run-test @tag')} run by tag`,
|
|
699
|
+
'',
|
|
700
|
+
`${pc.bold('Start the full UI')} ${pc.dim('(requires Docker)')}`,
|
|
701
|
+
` ${pc.cyan('plum server start')}`,
|
|
702
|
+
'',
|
|
703
|
+
`${pc.bold('Generate a step definition')}`,
|
|
704
|
+
` ${pc.cyan('plum create-step')}`
|
|
705
|
+
].join('\n'),
|
|
706
|
+
'Next steps'
|
|
697
707
|
);
|
|
698
|
-
|
|
708
|
+
clack.outro(pc.magenta('Plum is ready.'));
|
|
699
709
|
break;
|
|
710
|
+
}
|
|
700
711
|
|
|
701
712
|
case 'server':
|
|
702
713
|
if (subcommand === 'stop') {
|
|
@@ -734,10 +745,9 @@ switch (command) {
|
|
|
734
745
|
const userTestsPath = path.resolve(process.cwd(), 'tests');
|
|
735
746
|
const backendTestsPath = path.join(plumRoot, 'backend', 'tests');
|
|
736
747
|
|
|
737
|
-
|
|
738
|
-
if (browserArg && !validBrowsers.includes(browserArg)) {
|
|
748
|
+
if (browserArg && !VALID_BROWSERS.includes(browserArg)) {
|
|
739
749
|
console.error(
|
|
740
|
-
`✗ Invalid browser "${browserArg}". Choose one of: ${
|
|
750
|
+
`✗ Invalid browser "${browserArg}". Choose one of: ${VALID_BROWSERS.join(', ')}`
|
|
741
751
|
);
|
|
742
752
|
process.exit(1);
|
|
743
753
|
}
|
|
@@ -762,9 +772,9 @@ switch (command) {
|
|
|
762
772
|
// Install user-defined plugins from plum.plugins.json
|
|
763
773
|
installPlugins();
|
|
764
774
|
|
|
765
|
-
console.log('Running `npx playwright install`...');
|
|
775
|
+
console.log('Running `npx playwright install chromium firefox`...');
|
|
766
776
|
|
|
767
|
-
execSync('npx playwright install', {
|
|
777
|
+
execSync('npx playwright install chromium firefox', {
|
|
768
778
|
cwd: path.join(plumRoot, 'backend'),
|
|
769
779
|
stdio: 'inherit'
|
|
770
780
|
});
|
|
@@ -805,23 +815,39 @@ switch (command) {
|
|
|
805
815
|
|
|
806
816
|
case 'node': {
|
|
807
817
|
if (subcommand === 'stop') {
|
|
818
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Runner ')));
|
|
808
819
|
const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
809
|
-
|
|
810
|
-
console.log('🛑 Stopping Plum node...');
|
|
820
|
+
const { stopNode } = runnerProcessLib();
|
|
811
821
|
const cfg = loadNodeConfig(process.cwd());
|
|
812
|
-
|
|
822
|
+
|
|
823
|
+
if (cfg.id) {
|
|
824
|
+
const stopped = stopNode(String(cfg.id));
|
|
825
|
+
if (stopped) {
|
|
826
|
+
clack.log.success(`Stopped runner "${cfg.name ?? cfg.id}".`);
|
|
827
|
+
} else if (cfg.pid) {
|
|
828
|
+
try {
|
|
829
|
+
process.kill(cfg.pid, 'SIGTERM');
|
|
830
|
+
clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
|
|
831
|
+
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
832
|
+
} catch {
|
|
833
|
+
clack.log.info('No running process found — it may already be stopped.');
|
|
834
|
+
}
|
|
835
|
+
} else {
|
|
836
|
+
clack.log.info('No running process found — it may already be stopped.');
|
|
837
|
+
}
|
|
838
|
+
} else if (cfg.pid) {
|
|
813
839
|
try {
|
|
814
840
|
process.kill(cfg.pid, 'SIGTERM');
|
|
815
|
-
|
|
841
|
+
clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
|
|
842
|
+
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
816
843
|
} catch {
|
|
817
|
-
|
|
844
|
+
clack.log.info('No running process found — it may already be stopped.');
|
|
818
845
|
}
|
|
819
|
-
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
820
846
|
} else {
|
|
821
|
-
|
|
822
|
-
|
|
847
|
+
clack.log.info('No node started from this folder.');
|
|
848
|
+
clack.log.info(`Use ${pc.cyan('plum manage-runners')} to stop running nodes.`);
|
|
823
849
|
}
|
|
824
|
-
|
|
850
|
+
clack.outro(pc.dim('Done.'));
|
|
825
851
|
break;
|
|
826
852
|
}
|
|
827
853
|
|
|
@@ -834,6 +860,18 @@ switch (command) {
|
|
|
834
860
|
break;
|
|
835
861
|
}
|
|
836
862
|
|
|
863
|
+
case 'manage-runners': {
|
|
864
|
+
const { loadNodeConfig } = nodeRegisterLib();
|
|
865
|
+
const saved = loadNodeConfig(process.cwd());
|
|
866
|
+
const primaryUrl =
|
|
867
|
+
getFlag(process.argv.slice(3), '--primary') ??
|
|
868
|
+
process.env.PLUM_API_URL ??
|
|
869
|
+
saved.primary ??
|
|
870
|
+
'http://localhost:3001';
|
|
871
|
+
await openManageRunnersMenu(primaryUrl);
|
|
872
|
+
break;
|
|
873
|
+
}
|
|
874
|
+
|
|
837
875
|
case 'create-step': {
|
|
838
876
|
const createStepScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-step.mjs');
|
|
839
877
|
execSync(`node "${createStepScript}"`, {
|
|
@@ -859,8 +897,8 @@ switch (command) {
|
|
|
859
897
|
console.log(' --primary-url <url> Public URL node operators point --primary at');
|
|
860
898
|
console.log(' server reconfig Re-enter server settings without starting');
|
|
861
899
|
console.log(' server stop Stop the server (alias: plum stop)');
|
|
862
|
-
console.log(' node start Start a runner node (interactive)
|
|
863
|
-
console.log(' --primary <url> Primary Plum server to auto-register with
|
|
900
|
+
console.log(' node start Start a runner node (interactive), then open runner menu');
|
|
901
|
+
console.log(' --primary <url> Primary Plum server to auto-register with');
|
|
864
902
|
console.log(' --url <url> Address the primary calls back (default: <lan-ip>:<port>;');
|
|
865
903
|
console.log(
|
|
866
904
|
' pass a domain like https://node1.example behind a TLS proxy)'
|
|
@@ -868,13 +906,17 @@ switch (command) {
|
|
|
868
906
|
console.log(' --port <n> Local HTTP port the node listens on (default: 3001)');
|
|
869
907
|
console.log(' --token <secret> Auth token (auto-generated + saved if omitted)');
|
|
870
908
|
console.log(' --name <name> Runner name shown on the primary (default: node-<rand>)');
|
|
871
|
-
console.log(' --browser <name> chromium | firefox
|
|
909
|
+
console.log(' --browser <name> chromium | firefox (default: chromium)');
|
|
872
910
|
console.log(' node reconfig Re-enter node settings + re-register, without starting');
|
|
873
911
|
console.log(' node stop Stop the runner node started from this folder');
|
|
912
|
+
console.log(' manage-runners Open the runner management menu');
|
|
913
|
+
console.log(
|
|
914
|
+
' --primary <url> Primary server URL (default: saved config or localhost:3001)'
|
|
915
|
+
);
|
|
874
916
|
console.log(' run-test Run tests locally without Docker');
|
|
875
917
|
console.log(' @tag Run only tests matching a tag');
|
|
876
918
|
console.log(' --parallel <n> Run across n parallel workers');
|
|
877
|
-
console.log(' --browser <name> chromium | firefox
|
|
919
|
+
console.log(' --browser <name> chromium | firefox (default: chromium)');
|
|
878
920
|
console.log(' create-step Interactively scaffold a new step definition');
|
|
879
921
|
console.log('\n--------------------------------------\n');
|
|
880
922
|
}
|
package/frontend/src/app.css
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
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
1
|
/*
|
|
18
2
|
* This file is part of Plum.
|
|
19
3
|
*
|
|
@@ -19,8 +19,7 @@ export const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';
|
|
|
19
19
|
|
|
20
20
|
export const BROWSERS = [
|
|
21
21
|
{ id: 'chromium', label: 'Chrome' },
|
|
22
|
-
{ id: 'firefox', label: 'Firefox' }
|
|
23
|
-
{ id: 'webkit', label: 'WebKit' }
|
|
22
|
+
{ id: 'firefox', label: 'Firefox' }
|
|
24
23
|
];
|
|
25
24
|
|
|
26
25
|
export const TRIGGER_TYPES = Object.freeze({
|
|
@@ -15,23 +15,6 @@
|
|
|
15
15
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
/*
|
|
19
|
-
This file is part of Plum.
|
|
20
|
-
|
|
21
|
-
Plum is free software: you can redistribute it and/or modify
|
|
22
|
-
it under the terms of the GNU General Public License as published by
|
|
23
|
-
the Free Software Foundation, either version 3 of the License, or
|
|
24
|
-
(at your option) any later version.
|
|
25
|
-
|
|
26
|
-
Plum is distributed in the hope that it will be useful,
|
|
27
|
-
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
28
|
-
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
29
|
-
GNU General Public License for more details.
|
|
30
|
-
|
|
31
|
-
You should have received a copy of the GNU General Public License
|
|
32
|
-
along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
18
|
import { writable } from 'svelte/store';
|
|
36
19
|
import { browser } from '$app/environment';
|
|
37
20
|
|
|
@@ -1,19 +1,3 @@
|
|
|
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
1
|
/*
|
|
18
2
|
* This file is part of Plum.
|
|
19
3
|
*
|
|
@@ -1,19 +1,3 @@
|
|
|
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
1
|
/*
|
|
18
2
|
* This file is part of Plum.
|
|
19
3
|
*
|
|
@@ -1,19 +1,3 @@
|
|
|
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
1
|
/*
|
|
18
2
|
* This file is part of Plum.
|
|
19
3
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plum-e2e",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/silverlunah/plum.git"
|
|
@@ -29,10 +29,12 @@
|
|
|
29
29
|
"prettier-plugin-svelte": "^3.3.3"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"
|
|
32
|
+
"@clack/prompts": "^1.5.1",
|
|
33
|
+
"fs-extra": "^11.3.0",
|
|
34
|
+
"picocolors": "^1.1.1"
|
|
33
35
|
},
|
|
34
36
|
"type": "module",
|
|
35
37
|
"lint-staged": {
|
|
36
|
-
"*.{js,ts,svelte,json,md,css,html}": "prettier --write"
|
|
38
|
+
"*.{js,ts,mjs,svelte,json,md,css,html}": "prettier --write"
|
|
37
39
|
}
|
|
38
40
|
}
|