plum-e2e 1.3.4 → 1.3.6
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/config/scripts/run-tests.js +4 -2
- package/backend/lib/runnerProcess.js +2 -3
- package/backend/routes/node.routes.js +15 -5
- 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/components/layout/RunnerPanel.svelte +2 -2
- package/frontend/src/lib/constants.js +1 -4
- 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/frontend/src/routes/scheduled-tests/+page.svelte +70 -13
- package/package.json +5 -3
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: 'Your Plum server backend 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 Plum 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: 'The URL your Plum server calls to communicate with this node',
|
|
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
|
}
|