plum-e2e 1.3.1 → 1.3.3
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 +1 -1
- package/README.md +100 -26
- package/backend/app.js +10 -13
- package/backend/config/scripts/run-tests.js +60 -10
- package/backend/lib/nodeRegister.js +101 -0
- package/backend/lib/runnerProcess.js +180 -0
- package/backend/lib/serverConfig.js +112 -0
- package/backend/package-lock.json +2 -2
- package/backend/package.json +2 -2
- package/backend/scripts/manage-runners.mjs +297 -0
- package/backend/server.js +8 -5
- package/backend/services/reportService.js +17 -1
- package/backend/services/runnerService.js +18 -3
- package/backend/websockets/socketHandler.js +23 -3
- package/bin/plum.js +394 -102
- package/bin/scaffold-tests.js +34 -0
- package/frontend/package-lock.json +6 -1357
- package/frontend/package.json +1 -1
- package/frontend/src/lib/components/layout/RunnerPanel.svelte +29 -16
- package/frontend/src/lib/utils/format.js +13 -0
- package/frontend/src/routes/reports/[id]/+page.svelte +2 -30
- package/package.json +2 -2
- package/backend/scripts/add-local-runner.js +0 -120
package/CLAUDE.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
- **Frontend**: SvelteKit 5, port 5173. ESM, no TypeScript.
|
|
6
6
|
- **Backend**: Express + Socket.io, port 3001. CommonJS (`require`/`module.exports`).
|
|
7
7
|
- **Database**: PostgreSQL via Prisma ORM (`backend/services/prisma.js`).
|
|
8
|
-
- **Infrastructure**: Docker Compose
|
|
8
|
+
- **Infrastructure**: Docker Compose (`docker-compose.yml`) for the server stack. Runner nodes run as a bare `PLUM_MODE=node` Node process (started by `plum node start` / the dev `manage-runners` script), not via Docker.
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
## Requirements
|
|
17
17
|
|
|
18
18
|
- [Node.js](https://nodejs.org) v18 or higher
|
|
19
|
-
- [Docker](https://www.docker.com) — required for `plum server start`
|
|
19
|
+
- [Docker](https://www.docker.com) — required for `plum server start` (the web UI stack). Runner nodes (`plum node start`) run as a plain Node process and **do not need Docker**.
|
|
20
20
|
|
|
21
21
|
---
|
|
22
22
|
|
|
@@ -103,10 +103,34 @@ or the shorthand:
|
|
|
103
103
|
plum start
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
The first time you run it, Plum asks a few questions (press Enter to accept the default shown in parentheses):
|
|
107
|
+
|
|
108
|
+
| Prompt | Default | What it sets |
|
|
109
|
+
| ---------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
|
110
|
+
| **App URL (BASE_URL)** | from your `.env` | The URL Playwright opens at the start of every test |
|
|
111
|
+
| **Headless?** | `No` | Whether browsers run hidden |
|
|
112
|
+
| **Backend port** | `3001` | Host port for the API |
|
|
113
|
+
| **Frontend (UI) port** | `5173` | Host port for the web UI |
|
|
114
|
+
| **Primary public URL** | `http://<your-ip>:<port>` | The address you give runner nodes (see [Runner Setup](#4-runner-setup)) |
|
|
115
|
+
|
|
116
|
+
Your answers are saved to `.plum-server.json`, so the next `plum server start` reuses them without asking. When it finishes, open the UI at the frontend port it prints (default **http://localhost:5173**).
|
|
107
117
|
|
|
108
118
|
> Docker must be running before you use this command. Plum builds and starts the backend, database, and UI automatically.
|
|
109
119
|
|
|
120
|
+
**Skip the prompts** by passing flags (handy for CI):
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
plum server start --base-url https://your-app.com --headless true --backend-port 3001 --frontend-port 5173
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Change settings later
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
plum server reconfig
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Re-asks every question and saves your answers **without** starting the stack. Run `plum server start` afterwards to apply them.
|
|
133
|
+
|
|
110
134
|
### Stop
|
|
111
135
|
|
|
112
136
|
```bash
|
|
@@ -270,31 +294,69 @@ plum run-test --browser firefox # run in a specific browser
|
|
|
270
294
|
|
|
271
295
|
## 4. Runner Setup
|
|
272
296
|
|
|
273
|
-
Runners are additional machines that
|
|
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**.
|
|
274
298
|
|
|
275
|
-
###
|
|
299
|
+
### Start a runner node
|
|
276
300
|
|
|
277
301
|
On the machine that will act as a runner, navigate to your Plum project and run:
|
|
278
302
|
|
|
279
303
|
```bash
|
|
280
|
-
plum node start
|
|
304
|
+
plum node start
|
|
281
305
|
```
|
|
282
306
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
|
307
|
+
Plum asks a few questions and then **registers the node with your server automatically** — you don't need to add anything in the UI by hand:
|
|
308
|
+
|
|
309
|
+
| Prompt | Default | What it sets |
|
|
310
|
+
| ------------------ | ------------------------- | -------------------------------------------------------------------- |
|
|
311
|
+
| **Primary URL** | last used | The server this node registers with (e.g. `http://192.168.1.5:3001`) |
|
|
312
|
+
| **Local port** | `3001` | The port this node process listens on |
|
|
313
|
+
| **Advertised URL** | `http://<your-ip>:<port>` | The address the **server** uses to reach this node |
|
|
314
|
+
| **Runner name** | `node-<random>` | The name shown in the UI |
|
|
315
|
+
| **Browser** | `chromium` | Default browser for this node |
|
|
316
|
+
| **Auth token** | auto-generated | Shared secret; press Enter to keep the generated one |
|
|
286
317
|
|
|
287
|
-
|
|
318
|
+
When it registers, Plum prints a details card with the assigned **id**, **token**, and **url**, then boots the node (Ctrl+C to stop). Settings are saved to `.plum-node.json`, so re-running reuses them and never creates a duplicate.
|
|
288
319
|
|
|
289
|
-
|
|
320
|
+
**Skip the prompts** with flags (handy for CI or scripted nodes):
|
|
290
321
|
|
|
291
|
-
|
|
322
|
+
```bash
|
|
323
|
+
plum node start --primary http://192.168.1.5:3001 --port 3001 --name ci-node-1
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
| Flag | Description |
|
|
327
|
+
| ------------------ | ---------------------------------------------------------------------------------------- |
|
|
328
|
+
| `--primary <url>` | Server to auto-register with. Omit to only print the details for manual entry. |
|
|
329
|
+
| `--url <url>` | Address the server calls back. Defaults to `<lan-ip>:<port>`; pass a domain to override. |
|
|
330
|
+
| `--port <n>` | Local HTTP port the node listens on (default `3001`). |
|
|
331
|
+
| `--token <secret>` | Auth token. Auto-generated and saved if omitted. |
|
|
332
|
+
| `--name <name>` | Runner name shown in the UI. |
|
|
333
|
+
| `--browser <name>` | `chromium` (default), `firefox`, or `webkit`. |
|
|
334
|
+
|
|
335
|
+
#### Nodes behind a domain or reverse proxy
|
|
336
|
+
|
|
337
|
+
`--url` is the address the **server** calls back and is used exactly as given, while `--port` is the local port the node listens on. So a node behind an HTTPS reverse proxy is:
|
|
338
|
+
|
|
339
|
+
```bash
|
|
340
|
+
plum node start --primary https://plum.example.com --url https://node1.example.com --port 3001
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
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
|
+
|
|
345
|
+
### Change settings later
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
plum node reconfig
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Re-asks every question, re-registers with the primary, and prints the updated card — **without** starting the node. Run `plum node start` afterwards to launch it.
|
|
352
|
+
|
|
353
|
+
### Register manually (fallback)
|
|
354
|
+
|
|
355
|
+
If you run `plum node start` without a reachable `--primary`, Plum prints the node's **name, url, and token** instead. Add it by hand:
|
|
292
356
|
|
|
293
357
|
1. Open the Plum UI at **http://localhost:5173**
|
|
294
358
|
2. Go to **Settings → Runners**
|
|
295
|
-
3. Click **Add Runner** and
|
|
296
|
-
|
|
297
|
-
The runner will appear in the UI and can be selected when triggering tests.
|
|
359
|
+
3. Click **Add Runner** and paste the node's URL, token, and name
|
|
298
360
|
|
|
299
361
|
### Stop a runner node
|
|
300
362
|
|
|
@@ -302,22 +364,26 @@ The runner will appear in the UI and can be selected when triggering tests.
|
|
|
302
364
|
plum node stop
|
|
303
365
|
```
|
|
304
366
|
|
|
367
|
+
Stops the node started from the current folder.
|
|
368
|
+
|
|
305
369
|
---
|
|
306
370
|
|
|
307
371
|
## Command Reference
|
|
308
372
|
|
|
309
|
-
| Command | Description
|
|
310
|
-
| ----------------------------- |
|
|
311
|
-
| `plum init` | Initialize a new project in the current folder
|
|
312
|
-
| `plum server start` | Start the full UI stack via Docker (alias: `plum start`) |
|
|
313
|
-
| `plum server
|
|
314
|
-
| `plum
|
|
315
|
-
| `plum run-test
|
|
316
|
-
| `plum run-test
|
|
317
|
-
| `plum run-test --
|
|
318
|
-
| `plum
|
|
319
|
-
| `plum
|
|
320
|
-
| `plum node
|
|
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/firefox/webkit) |
|
|
383
|
+
| `plum create-step` | Interactively scaffold a new step definition |
|
|
384
|
+
| `plum node start` | Start a runner node (interactive) and auto-register it with the server |
|
|
385
|
+
| `plum node reconfig` | Re-enter node settings + re-register, without starting |
|
|
386
|
+
| `plum node stop` | Stop the runner node started from this folder |
|
|
321
387
|
|
|
322
388
|
---
|
|
323
389
|
|
|
@@ -379,6 +445,14 @@ npm run create-test
|
|
|
379
445
|
|
|
380
446
|
Interactive prompt that creates a new `.feature` file, page object, and step definition file from a template — ready for you to implement.
|
|
381
447
|
|
|
448
|
+
**Manage runner nodes:**
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
npm run manage-runners
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
One interactive menu to **add** a new runner (registers it with the primary and optionally starts it), and to **start / stop / restart / ping** the local node processes it manages. In dev the primary runs in Docker and nodes run as bare host processes, reached via `host.docker.internal`. PIDs are tracked in `.runners.local.json` and logs go to `backend/logs/` so you can manage nodes across terminal sessions.
|
|
455
|
+
|
|
382
456
|
### Test file locations
|
|
383
457
|
|
|
384
458
|
```
|
package/backend/app.js
CHANGED
|
@@ -28,20 +28,17 @@ app.use(express.json());
|
|
|
28
28
|
app.use('/screenshots', express.static(SCREENSHOTS_DIR));
|
|
29
29
|
|
|
30
30
|
// Routes
|
|
31
|
-
const testRoutes = require('./routes/tests.routes');
|
|
32
|
-
const reportRoutes = require('./routes/reports.routes');
|
|
33
|
-
const cronRoutes = require('./routes/cron.routes');
|
|
34
|
-
const settingsRoutes = require('./routes/settings.routes');
|
|
35
|
-
const backupRoutes = require('./routes/backup.routes');
|
|
36
|
-
const runnerRoutes = require('./routes/runners.routes');
|
|
37
31
|
const nodeRoutes = require('./routes/node.routes');
|
|
38
|
-
|
|
39
|
-
app.use('/tests', testRoutes);
|
|
40
|
-
app.use('/reports', reportRoutes);
|
|
41
|
-
app.use('/cron-jobs', cronRoutes);
|
|
42
|
-
app.use('/settings', settingsRoutes);
|
|
43
|
-
app.use('/backup', backupRoutes);
|
|
44
|
-
app.use('/runners', runnerRoutes);
|
|
45
32
|
app.use('/api', nodeRoutes);
|
|
46
33
|
|
|
34
|
+
// Primary-mode routes — skipped when running as a runner node (no DB available)
|
|
35
|
+
if (process.env.PLUM_MODE !== 'node') {
|
|
36
|
+
app.use('/tests', require('./routes/tests.routes'));
|
|
37
|
+
app.use('/reports', require('./routes/reports.routes'));
|
|
38
|
+
app.use('/cron-jobs', require('./routes/cron.routes'));
|
|
39
|
+
app.use('/settings', require('./routes/settings.routes'));
|
|
40
|
+
app.use('/backup', require('./routes/backup.routes'));
|
|
41
|
+
app.use('/runners', require('./routes/runners.routes'));
|
|
42
|
+
}
|
|
43
|
+
|
|
47
44
|
module.exports = app;
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
const { execSync } = require('child_process');
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
19
21
|
const pc = require('picocolors');
|
|
20
22
|
|
|
21
23
|
const parallelIdx = process.argv.indexOf('--parallel');
|
|
@@ -25,28 +27,68 @@ const runners = parallel || process.env.REPORT_RUNNERS || '1';
|
|
|
25
27
|
const tag = process.env.TAG || process.argv.slice(2).find((a) => a.startsWith('@'));
|
|
26
28
|
const browser = process.env.BROWSER || 'chromium';
|
|
27
29
|
|
|
30
|
+
const reportFile = path.resolve(process.cwd(), 'reports', 'cucumber_report.json');
|
|
31
|
+
|
|
32
|
+
// Wipe any previous report so a crashed/empty run can never return stale results.
|
|
33
|
+
try {
|
|
34
|
+
fs.rmSync(reportFile, { force: true });
|
|
35
|
+
} catch {}
|
|
36
|
+
|
|
28
37
|
let testExitCode = 0;
|
|
29
38
|
|
|
30
39
|
try {
|
|
31
40
|
const testsRoot = (process.env.TESTS_ROOT || 'tests').replace(/\\/g, '/');
|
|
32
41
|
|
|
42
|
+
// Dispatched tests run from an external dir (e.g. a temp dir on a node) that has
|
|
43
|
+
// no node_modules of its own — point Node at the backend's modules so imports
|
|
44
|
+
// like 'playwright' and '@cucumber/cucumber' resolve.
|
|
45
|
+
const nodeModulesPath = path.resolve(__dirname, '..', '..', 'node_modules');
|
|
46
|
+
|
|
33
47
|
const baseCommand = [
|
|
34
48
|
'npx',
|
|
35
49
|
'cross-env',
|
|
36
50
|
`BROWSER=${browser}`,
|
|
37
51
|
'TS_NODE_TRANSPILE_ONLY=true',
|
|
38
52
|
'cucumber-js',
|
|
39
|
-
`${testsRoot}/features/**/*.feature
|
|
40
|
-
'--require-module',
|
|
41
|
-
'ts-node/register',
|
|
42
|
-
'--require',
|
|
43
|
-
`${testsRoot}/utils/hooks.ts`,
|
|
44
|
-
'--require',
|
|
45
|
-
`${testsRoot}/step_definitions/**/*.ts`,
|
|
46
|
-
'--format',
|
|
47
|
-
'json:reports/cucumber_report.json'
|
|
53
|
+
`${testsRoot}/features/**/*.feature`
|
|
48
54
|
];
|
|
49
55
|
|
|
56
|
+
// A dispatched run executes test files in a temp dir on a runner node. The node's
|
|
57
|
+
// own working directory ships a cucumber.json whose `require` globs point at the
|
|
58
|
+
// node's *local* tests/ — and cucumber merges config-file `require` additively with
|
|
59
|
+
// the CLI ones, so every step would match two definitions (local + dispatched) and
|
|
60
|
+
// run as "ambiguous": never executing, reporting zero duration. Point cucumber at a
|
|
61
|
+
// self-contained config inside the dispatched dir so the node's own cucumber.json is
|
|
62
|
+
// skipped entirely (an explicit --config disables auto-discovery of the cwd file).
|
|
63
|
+
if (process.env.TESTS_ROOT) {
|
|
64
|
+
const testsRootAbs = path.resolve(testsRoot);
|
|
65
|
+
const configPath = path.join(testsRootAbs, 'cucumber.json');
|
|
66
|
+
fs.writeFileSync(
|
|
67
|
+
configPath,
|
|
68
|
+
JSON.stringify({
|
|
69
|
+
default: {
|
|
70
|
+
requireModule: ['ts-node/register'],
|
|
71
|
+
require: [
|
|
72
|
+
`${testsRootAbs}/utils/hooks.ts`.replace(/\\/g, '/'),
|
|
73
|
+
`${testsRootAbs}/step_definitions/**/*.ts`.replace(/\\/g, '/')
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
baseCommand.push('--config', path.relative(process.cwd(), configPath).replace(/\\/g, '/'));
|
|
79
|
+
} else {
|
|
80
|
+
baseCommand.push(
|
|
81
|
+
'--require-module',
|
|
82
|
+
'ts-node/register',
|
|
83
|
+
'--require',
|
|
84
|
+
`${testsRoot}/utils/hooks.ts`,
|
|
85
|
+
'--require',
|
|
86
|
+
`${testsRoot}/step_definitions/**/*.ts`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
baseCommand.push('--format', 'json:reports/cucumber_report.json');
|
|
91
|
+
|
|
50
92
|
if (tag) {
|
|
51
93
|
baseCommand.push('--tags', `"${tag}"`);
|
|
52
94
|
}
|
|
@@ -56,7 +98,15 @@ try {
|
|
|
56
98
|
}
|
|
57
99
|
|
|
58
100
|
const cucumberCommand = baseCommand.join(' ');
|
|
59
|
-
execSync(cucumberCommand, {
|
|
101
|
+
execSync(cucumberCommand, {
|
|
102
|
+
stdio: 'inherit',
|
|
103
|
+
env: {
|
|
104
|
+
...process.env,
|
|
105
|
+
NODE_PATH: process.env.NODE_PATH
|
|
106
|
+
? `${nodeModulesPath}${path.delimiter}${process.env.NODE_PATH}`
|
|
107
|
+
: nodeModulesPath
|
|
108
|
+
}
|
|
109
|
+
});
|
|
60
110
|
} catch (error) {
|
|
61
111
|
// Cucumber exits non-zero when scenarios fail — preserve it so callers see the real result.
|
|
62
112
|
testExitCode = error.status ?? 1;
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Helpers for the operator-facing `plum node` flow: generate a node auth token,
|
|
20
|
+
* guess a reachable address, persist the node's identity, and self-register the
|
|
21
|
+
* node with a primary Plum server.
|
|
22
|
+
*
|
|
23
|
+
* Uses only Node builtins (os/crypto/fetch) so it runs before backend deps are
|
|
24
|
+
* installed and can be imported from the published `bin/plum.js`.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
const crypto = require('crypto');
|
|
31
|
+
|
|
32
|
+
const CONFIG_FILENAME = '.plum-node.json';
|
|
33
|
+
|
|
34
|
+
function generateToken() {
|
|
35
|
+
return crypto.randomBytes(24).toString('hex');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** First non-internal IPv4 address, or 'localhost' if none is found. */
|
|
39
|
+
function detectLanIp() {
|
|
40
|
+
for (const addrs of Object.values(os.networkInterfaces())) {
|
|
41
|
+
for (const addr of addrs ?? []) {
|
|
42
|
+
if (addr.family === 'IPv4' && !addr.internal) return addr.address;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return 'localhost';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function configPath(dir) {
|
|
49
|
+
return path.join(dir, CONFIG_FILENAME);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function loadNodeConfig(dir) {
|
|
53
|
+
try {
|
|
54
|
+
return JSON.parse(fs.readFileSync(configPath(dir), 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function saveNodeConfig(dir, cfg) {
|
|
61
|
+
fs.writeFileSync(configPath(dir), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Registers the node with the primary, reusing an existing runner whose name+url
|
|
66
|
+
* match instead of creating a duplicate.
|
|
67
|
+
*
|
|
68
|
+
* @returns {Promise<{ id: string, reused: boolean }>}
|
|
69
|
+
* @throws {Error} when the primary is unreachable or rejects the request
|
|
70
|
+
*/
|
|
71
|
+
async function registerWithPrimary({ primary, name, url, token, browser }) {
|
|
72
|
+
const base = primary.replace(/\/$/, '');
|
|
73
|
+
|
|
74
|
+
let existing = null;
|
|
75
|
+
const listRes = await fetch(`${base}/runners`, { signal: AbortSignal.timeout(10000) });
|
|
76
|
+
if (!listRes.ok) throw new Error(`primary returned HTTP ${listRes.status} listing runners`);
|
|
77
|
+
const { runners = [] } = await listRes.json();
|
|
78
|
+
existing = runners.find((r) => r.name === name && r.url === url) ?? null;
|
|
79
|
+
if (existing) return { id: existing.id, reused: true };
|
|
80
|
+
|
|
81
|
+
const res = await fetch(`${base}/runners`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
body: JSON.stringify({ name, url, token, browser }),
|
|
85
|
+
signal: AbortSignal.timeout(10000)
|
|
86
|
+
});
|
|
87
|
+
const body = await res.json().catch(() => ({}));
|
|
88
|
+
if (!res.ok || body.error) {
|
|
89
|
+
throw new Error(body.error || `primary returned HTTP ${res.status}`);
|
|
90
|
+
}
|
|
91
|
+
return { id: body.runner.id, reused: false };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
CONFIG_FILENAME,
|
|
96
|
+
generateToken,
|
|
97
|
+
detectLanIp,
|
|
98
|
+
loadNodeConfig,
|
|
99
|
+
saveNodeConfig,
|
|
100
|
+
registerWithPrimary
|
|
101
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* Lifecycle helpers for local node-runner processes.
|
|
20
|
+
*
|
|
21
|
+
* A runner is registered in the primary DB, but the actual worker is an
|
|
22
|
+
* independent `PLUM_MODE=node` process. This module starts those processes
|
|
23
|
+
* detached (so they outlive the launching terminal) and tracks their PIDs in a
|
|
24
|
+
* small JSON registry so a later CLI invocation can stop or restart them.
|
|
25
|
+
*
|
|
26
|
+
* Only runners whose URL points at this machine can be controlled here.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { spawn, execSync } = require('child_process');
|
|
32
|
+
|
|
33
|
+
const BACKEND_DIR = path.resolve(__dirname, '..');
|
|
34
|
+
const SERVER_PATH = path.join(BACKEND_DIR, 'server.js');
|
|
35
|
+
const REGISTRY_PATH = path.join(BACKEND_DIR, '.runners.local.json');
|
|
36
|
+
const LOGS_DIR = path.join(BACKEND_DIR, 'logs');
|
|
37
|
+
|
|
38
|
+
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', 'host.docker.internal']);
|
|
39
|
+
|
|
40
|
+
function loadRegistry() {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function saveRegistry(registry) {
|
|
49
|
+
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(registry, null, 2), 'utf8');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Signal 0 performs the OS-level permission/existence check without actually
|
|
53
|
+
// delivering a signal — the portable way to ask "is this pid alive?".
|
|
54
|
+
function isAlive(pid) {
|
|
55
|
+
try {
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLocalUrl(url) {
|
|
64
|
+
try {
|
|
65
|
+
return LOCAL_HOSTS.has(new URL(url).hostname);
|
|
66
|
+
} catch {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parsePort(url) {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(url).port || '3001';
|
|
74
|
+
} catch {
|
|
75
|
+
return '3001';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Drops registry entries whose process has died and persists the result. */
|
|
80
|
+
function pruneDead(registry = loadRegistry()) {
|
|
81
|
+
let changed = false;
|
|
82
|
+
for (const [id, entry] of Object.entries(registry)) {
|
|
83
|
+
if (!entry?.pid || !isAlive(entry.pid)) {
|
|
84
|
+
delete registry[id];
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (changed) saveRegistry(registry);
|
|
89
|
+
return registry;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 'running' if this manager owns a live process for the runner, else 'stopped'. */
|
|
93
|
+
function statusOf(id, registry = loadRegistry()) {
|
|
94
|
+
const entry = registry[id];
|
|
95
|
+
return entry?.pid && isAlive(entry.pid) ? 'running' : 'stopped';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Ensures a local node can actually run tests: backend dependencies must be
|
|
100
|
+
* installed and the Playwright browser binaries present. A node with neither
|
|
101
|
+
* starts fine but every scenario fails at the browser-launch hook. Idempotent —
|
|
102
|
+
* npm install is skipped when node_modules already exists, and `playwright
|
|
103
|
+
* install` no-ops when the browser is already downloaded.
|
|
104
|
+
*
|
|
105
|
+
* npm/npx are .cmd shims on Windows, so these must run through a shell.
|
|
106
|
+
*
|
|
107
|
+
* stdin is left as 'ignore' (not inherited): these installers only need to
|
|
108
|
+
* print progress, and letting them touch stdin corrupts the raw-mode state of
|
|
109
|
+
* an interactive prompt running in the same terminal (the caller's menu would
|
|
110
|
+
* wedge after the install finishes).
|
|
111
|
+
*/
|
|
112
|
+
function prepareEnv(browser) {
|
|
113
|
+
const stdio = ['ignore', 'inherit', 'inherit'];
|
|
114
|
+
if (!fs.existsSync(path.join(BACKEND_DIR, 'node_modules'))) {
|
|
115
|
+
execSync('npm install', { cwd: BACKEND_DIR, stdio, shell: true });
|
|
116
|
+
}
|
|
117
|
+
const target = browser && browser !== 'all' ? ` ${browser}` : '';
|
|
118
|
+
execSync(`npx playwright install${target}`, { cwd: BACKEND_DIR, stdio, shell: true });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Spawns a detached node-mode server for the given runner and records its pid.
|
|
123
|
+
* Returns the registry entry.
|
|
124
|
+
*/
|
|
125
|
+
function startNode({ id, port, token }) {
|
|
126
|
+
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
127
|
+
const logFile = path.join(LOGS_DIR, `runner-${id}.log`);
|
|
128
|
+
const out = fs.openSync(logFile, 'a');
|
|
129
|
+
|
|
130
|
+
const child = spawn(process.execPath, [SERVER_PATH], {
|
|
131
|
+
cwd: BACKEND_DIR,
|
|
132
|
+
env: { ...process.env, NODE_TOKEN: token, PLUM_MODE: 'node', PORT: String(port) },
|
|
133
|
+
detached: true,
|
|
134
|
+
stdio: ['ignore', out, out],
|
|
135
|
+
windowsHide: true
|
|
136
|
+
});
|
|
137
|
+
child.unref();
|
|
138
|
+
fs.closeSync(out);
|
|
139
|
+
|
|
140
|
+
const registry = loadRegistry();
|
|
141
|
+
const entry = { pid: child.pid, port: String(port), logFile, startedAt: Date.now() };
|
|
142
|
+
registry[id] = entry;
|
|
143
|
+
saveRegistry(registry);
|
|
144
|
+
return entry;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Stops the managed process for a runner. Returns true if a live process was
|
|
149
|
+
* signalled, false if nothing was running.
|
|
150
|
+
*/
|
|
151
|
+
function stopNode(id) {
|
|
152
|
+
const registry = loadRegistry();
|
|
153
|
+
const entry = registry[id];
|
|
154
|
+
let signalled = false;
|
|
155
|
+
if (entry?.pid && isAlive(entry.pid)) {
|
|
156
|
+
try {
|
|
157
|
+
process.kill(entry.pid, 'SIGTERM');
|
|
158
|
+
signalled = true;
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
delete registry[id];
|
|
162
|
+
saveRegistry(registry);
|
|
163
|
+
return signalled;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
BACKEND_DIR,
|
|
168
|
+
LOGS_DIR,
|
|
169
|
+
REGISTRY_PATH,
|
|
170
|
+
loadRegistry,
|
|
171
|
+
saveRegistry,
|
|
172
|
+
isAlive,
|
|
173
|
+
isLocalUrl,
|
|
174
|
+
parsePort,
|
|
175
|
+
pruneDead,
|
|
176
|
+
statusOf,
|
|
177
|
+
prepareEnv,
|
|
178
|
+
startNode,
|
|
179
|
+
stopNode
|
|
180
|
+
};
|