plum-e2e 1.3.3 → 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.
Files changed (53) hide show
  1. package/README.md +41 -24
  2. package/backend/config/scripts/create-step.mjs +15 -14
  3. package/backend/lib/runnerProcess.js +2 -3
  4. package/backend/scripts/manage-runners.mjs +6 -17
  5. package/backend/services/cronService.js +16 -5
  6. package/backend/websockets/socketHandler.js +23 -5
  7. package/bin/plum.js +262 -220
  8. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
  9. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/Button.cBruH0aD.css +17 -0
  10. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
  11. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.BVnUajEa.css +17 -0
  12. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
  13. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
  14. package/frontend/.svelte-kit/adapter-node/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
  15. package/frontend/.svelte-kit/output/client/_app/immutable/assets/0.CnXRuPt4.css +17 -0
  16. package/frontend/.svelte-kit/output/client/_app/immutable/assets/2.CGnCsn5q.css +17 -0
  17. package/frontend/.svelte-kit/output/client/_app/immutable/assets/3.BVnUajEa.css +17 -0
  18. package/frontend/.svelte-kit/output/client/_app/immutable/assets/4.DBhBrHFz.css +17 -0
  19. package/frontend/.svelte-kit/output/client/_app/immutable/assets/5.D93VAB-w.css +17 -0
  20. package/frontend/.svelte-kit/output/client/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
  21. package/frontend/.svelte-kit/output/client/_app/immutable/assets/Button.cBruH0aD.css +17 -0
  22. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
  23. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.BVnUajEa.css +17 -0
  24. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
  25. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
  26. package/frontend/.svelte-kit/output/client/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
  27. package/frontend/.svelte-kit/output/server/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
  28. package/frontend/.svelte-kit/output/server/_app/immutable/assets/Button.cBruH0aD.css +17 -0
  29. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
  30. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.BVnUajEa.css +17 -0
  31. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
  32. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
  33. package/frontend/.svelte-kit/output/server/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
  34. package/frontend/build/client/_app/immutable/assets/0.CnXRuPt4.css +17 -0
  35. package/frontend/build/client/_app/immutable/assets/2.CGnCsn5q.css +17 -0
  36. package/frontend/build/client/_app/immutable/assets/3.BVnUajEa.css +17 -0
  37. package/frontend/build/client/_app/immutable/assets/4.DBhBrHFz.css +17 -0
  38. package/frontend/build/client/_app/immutable/assets/5.D93VAB-w.css +17 -0
  39. package/frontend/build/client/_app/immutable/assets/Badge.DLLowvEA.css +17 -0
  40. package/frontend/build/client/_app/immutable/assets/Button.cBruH0aD.css +17 -0
  41. package/frontend/build/client/_app/immutable/assets/_layout.D7eM-6MV.css +17 -0
  42. package/frontend/build/client/_app/immutable/assets/_page.BVnUajEa.css +17 -0
  43. package/frontend/build/client/_app/immutable/assets/_page.CGnCsn5q.css +17 -0
  44. package/frontend/build/client/_app/immutable/assets/_page.DBhBrHFz.css +17 -0
  45. package/frontend/build/client/_app/immutable/assets/_page.DOqo0UR4.css +17 -0
  46. package/frontend/src/app.css +0 -16
  47. package/frontend/src/lib/constants.js +1 -2
  48. package/frontend/src/lib/stores/theme.js +0 -17
  49. package/frontend/src/lib/styles/global.css +0 -16
  50. package/frontend/src/lib/styles/reset.css +0 -16
  51. package/frontend/src/lib/styles/tokens.css +0 -16
  52. package/frontend/src/routes/reports/live/+page.svelte +2 -2
  53. 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), `firefox`, or `webkit` |
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 and then **registers the node with your server automatically** you don't need to add anything in the UI by hand:
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
- 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.
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/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 |
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(browser) {
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
- const target = browser && browser !== 'all' ? ` ${browser}` : '';
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(browser) {
107
- clack.log.step(`Preparing node environment (deps + ${browser ?? 'browser'})...`);
106
+ function prepareNodeEnv() {
107
+ clack.log.step('Preparing node environment (deps + browsers)...');
108
108
  try {
109
- prepareEnv(browser);
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(r.browser);
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(browser);
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
- const collectedReports = new Array(runnerIds.length).fill(null);
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 === runnerIds.length) {
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 < runnerIds.length; i++) {
130
+ for (let i = 0; i < activeRunnerIds.length; i++) {
120
131
  const lane = laneInfos[i];
121
- const chunkTag = chunks[i]?.length > 0 ? buildTagExpression(chunks[i]) : tags;
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
- runnerIds.map(async (id) => {
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: (chunks[i] || []).length }))
149
+ laneInfos.map((l, i) => ({ id: l.id, name: l.name, testCount: chunks[i].length }))
132
150
  );
133
151
 
134
- const total = runnerIds.length;
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 < runnerIds.length; i++) {
189
+ for (let i = 0; i < activeRunnerIds.length; i++) {
172
190
  const lane = laneInfos[i];
173
- const chunkTag = chunks[i]?.length > 0 ? buildTagExpression(chunks[i]) : tag;
191
+ const chunkTag = buildTagExpression(chunks[i]);
174
192
 
175
193
  if (lane.id === BUILT_IN_RUNNER_ID) {
176
194
  const env = {