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/bin/plum.js
CHANGED
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
* along with Plum. If not, see https://www.gnu.org/licenses/.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { execSync } from 'child_process';
|
|
19
|
+
import { execSync, spawn } from 'child_process';
|
|
20
20
|
import fs from 'fs';
|
|
21
21
|
import path from 'path';
|
|
22
|
+
import readline from 'readline';
|
|
22
23
|
import { fileURLToPath } from 'url';
|
|
24
|
+
import { createRequire } from 'module';
|
|
23
25
|
import fse from 'fs-extra';
|
|
24
26
|
|
|
25
27
|
const __filename = fileURLToPath(import.meta.url);
|
|
26
28
|
const __dirname = path.dirname(__filename);
|
|
29
|
+
const require = createRequire(import.meta.url);
|
|
27
30
|
const command = process.argv[2];
|
|
28
31
|
const subcommand = process.argv[3];
|
|
29
32
|
const plumRoot = path.resolve(__dirname, '..');
|
|
@@ -134,6 +137,350 @@ function copyEnvFile() {
|
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
const backendLib = path.join(plumRoot, 'backend', 'lib');
|
|
141
|
+
const serverConfigLib = () => require(path.join(backendLib, 'serverConfig.js'));
|
|
142
|
+
const nodeRegisterLib = () => require(path.join(backendLib, 'nodeRegister.js'));
|
|
143
|
+
|
|
144
|
+
/* -----------------------------------------------------
|
|
145
|
+
* Interactive prompts
|
|
146
|
+
* ------------------------------------------------------ */
|
|
147
|
+
|
|
148
|
+
const interactiveAllowed = () => Boolean(process.stdin.isTTY);
|
|
149
|
+
const getFlag = (args, name) => {
|
|
150
|
+
const i = args.indexOf(name);
|
|
151
|
+
return i !== -1 ? args[i + 1] : undefined;
|
|
152
|
+
};
|
|
153
|
+
const anyFlags = (args, names) => names.some((n) => args.includes(n));
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A line-buffered prompter that works for both an interactive TTY and piped
|
|
157
|
+
* stdin. Non-TTY input emits every line then closes immediately, so we queue
|
|
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() };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const VALID_BROWSERS = ['chromium', 'firefox', 'webkit'];
|
|
213
|
+
|
|
214
|
+
/* -----------------------------------------------------
|
|
215
|
+
* Server flow
|
|
216
|
+
* ------------------------------------------------------ */
|
|
217
|
+
|
|
218
|
+
function mergeUserPlugins() {
|
|
219
|
+
const userPluginsPath = path.join(process.cwd(), 'plum.plugins.json');
|
|
220
|
+
if (!fs.existsSync(userPluginsPath)) return;
|
|
221
|
+
try {
|
|
222
|
+
const userPlugins = JSON.parse(fs.readFileSync(userPluginsPath, 'utf8'));
|
|
223
|
+
const backendPkgPath = path.join(plumRoot, 'backend', 'package.json');
|
|
224
|
+
const backendPkg = JSON.parse(fs.readFileSync(backendPkgPath, 'utf8'));
|
|
225
|
+
const pluginDeps = userPlugins.dependencies ?? {};
|
|
226
|
+
if (Object.keys(pluginDeps).length > 0) {
|
|
227
|
+
backendPkg.dependencies = { ...backendPkg.dependencies, ...pluginDeps };
|
|
228
|
+
fs.writeFileSync(backendPkgPath, JSON.stringify(backendPkg, null, '\t') + '\n', 'utf8');
|
|
229
|
+
console.log(`đĻ Merged plugins into backend: ${Object.keys(pluginDeps).join(', ')}\n`);
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
console.log('â ī¸ Could not read plum.plugins.json. Skipping plugin merge.\n');
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function configureServer({ force }) {
|
|
237
|
+
const { loadServerConfig, saveServerConfig } = serverConfigLib();
|
|
238
|
+
const cwd = process.cwd();
|
|
239
|
+
const args = process.argv.slice(3);
|
|
240
|
+
const cfg = loadServerConfig(cwd);
|
|
241
|
+
|
|
242
|
+
const overrides = {
|
|
243
|
+
baseUrl: getFlag(args, '--base-url'),
|
|
244
|
+
headless: getFlag(args, '--headless'),
|
|
245
|
+
backendPort: getFlag(args, '--backend-port'),
|
|
246
|
+
frontendPort: getFlag(args, '--frontend-port'),
|
|
247
|
+
primaryPublicUrl: getFlag(args, '--primary-url')
|
|
248
|
+
};
|
|
249
|
+
if (overrides.baseUrl !== undefined) cfg.baseUrl = overrides.baseUrl;
|
|
250
|
+
if (overrides.headless !== undefined) cfg.headless = overrides.headless === 'true';
|
|
251
|
+
if (overrides.backendPort !== undefined) cfg.backendPort = overrides.backendPort;
|
|
252
|
+
if (overrides.frontendPort !== undefined) cfg.frontendPort = overrides.frontendPort;
|
|
253
|
+
if (overrides.primaryPublicUrl !== undefined) cfg.primaryPublicUrl = overrides.primaryPublicUrl;
|
|
254
|
+
|
|
255
|
+
const hasFlags = anyFlags(args, [
|
|
256
|
+
'--base-url',
|
|
257
|
+
'--headless',
|
|
258
|
+
'--backend-port',
|
|
259
|
+
'--frontend-port',
|
|
260
|
+
'--primary-url'
|
|
261
|
+
]);
|
|
262
|
+
const interactive = force || (interactiveAllowed() && !hasFlags);
|
|
263
|
+
|
|
264
|
+
if (interactive) {
|
|
265
|
+
const p = createPrompter();
|
|
266
|
+
try {
|
|
267
|
+
cfg.baseUrl = await p.ask('App URL to test (BASE_URL)', cfg.baseUrl);
|
|
268
|
+
cfg.headless = await p.askYesNo('Run browsers headless?', cfg.headless);
|
|
269
|
+
cfg.backendPort = await p.ask('Backend port', cfg.backendPort);
|
|
270
|
+
cfg.frontendPort = await p.ask('Frontend (UI) port', cfg.frontendPort);
|
|
271
|
+
cfg.primaryPublicUrl = await p.ask(
|
|
272
|
+
'Primary public URL (share with node operators)',
|
|
273
|
+
cfg.primaryPublicUrl
|
|
274
|
+
);
|
|
275
|
+
} finally {
|
|
276
|
+
p.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
saveServerConfig(cwd, cfg);
|
|
281
|
+
return cfg;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function applyServerConfig(cfg) {
|
|
285
|
+
const { writeEnvFile, buildOverrideYaml } = serverConfigLib();
|
|
286
|
+
const cwd = process.cwd();
|
|
287
|
+
writeEnvFile(cwd, cfg);
|
|
288
|
+
copyEnvFile();
|
|
289
|
+
mergeUserPlugins();
|
|
290
|
+
const testsAbs = path.resolve(cwd, 'tests').replace(/\\/g, '/');
|
|
291
|
+
const reportsAbs = path.resolve(cwd, 'reports').replace(/\\/g, '/');
|
|
292
|
+
fs.writeFileSync(
|
|
293
|
+
overrideFilePath,
|
|
294
|
+
buildOverrideYaml({
|
|
295
|
+
testsAbs,
|
|
296
|
+
reportsAbs,
|
|
297
|
+
backendPort: cfg.backendPort,
|
|
298
|
+
frontendPort: cfg.frontendPort
|
|
299
|
+
}),
|
|
300
|
+
'utf8'
|
|
301
|
+
);
|
|
302
|
+
console.log('â
docker-compose.override.yml written');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function serverStart() {
|
|
306
|
+
console.log('--------------------------------------\n');
|
|
307
|
+
console.log('đ Starting Plum...\n');
|
|
308
|
+
const cfg = await configureServer({ force: false });
|
|
309
|
+
applyServerConfig(cfg);
|
|
310
|
+
console.log(`\nđŖ UI: http://localhost:${cfg.frontendPort}`);
|
|
311
|
+
console.log(` Nodes register against: ${cfg.primaryPublicUrl}\n`);
|
|
312
|
+
execSync('docker compose up --build', { cwd: plumRoot, stdio: 'inherit' });
|
|
313
|
+
console.log('--------------------------------------\n');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function serverReconfig() {
|
|
317
|
+
console.log('--------------------------------------\n');
|
|
318
|
+
console.log('âī¸ Reconfiguring Plum server...\n');
|
|
319
|
+
const cfg = await configureServer({ force: true });
|
|
320
|
+
applyServerConfig(cfg);
|
|
321
|
+
console.log('\nâ
Saved. Run `plum server start` to apply.');
|
|
322
|
+
console.log(` UI will be: http://localhost:${cfg.frontendPort}`);
|
|
323
|
+
console.log(` Nodes register against: ${cfg.primaryPublicUrl}`);
|
|
324
|
+
console.log('--------------------------------------\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* -----------------------------------------------------
|
|
328
|
+
* Node flow
|
|
329
|
+
* ------------------------------------------------------ */
|
|
330
|
+
|
|
331
|
+
async function configureNode({ force }) {
|
|
332
|
+
const { generateToken, detectLanIp, loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
333
|
+
const cwd = process.cwd();
|
|
334
|
+
const args = process.argv.slice(3);
|
|
335
|
+
const saved = loadNodeConfig(cwd);
|
|
336
|
+
|
|
337
|
+
let primary = getFlag(args, '--primary') ?? process.env.PRIMARY_URL ?? saved.primary ?? '';
|
|
338
|
+
let port = getFlag(args, '--port') ?? saved.port ?? '3001';
|
|
339
|
+
let browser = getFlag(args, '--browser') ?? saved.browser ?? 'chromium';
|
|
340
|
+
let token = getFlag(args, '--token') ?? process.env.NODE_TOKEN ?? saved.token ?? generateToken();
|
|
341
|
+
let name = getFlag(args, '--name') ?? saved.name ?? `node-${token.slice(0, 6)}`;
|
|
342
|
+
// A provided --url is advertised verbatim; otherwise fall back to host:port.
|
|
343
|
+
let url = getFlag(args, '--url') ?? saved.url ?? '';
|
|
344
|
+
|
|
345
|
+
const hasFlags = anyFlags(args, [
|
|
346
|
+
'--primary',
|
|
347
|
+
'--url',
|
|
348
|
+
'--port',
|
|
349
|
+
'--token',
|
|
350
|
+
'--name',
|
|
351
|
+
'--browser'
|
|
352
|
+
]);
|
|
353
|
+
const interactive = force || (interactiveAllowed() && !hasFlags);
|
|
354
|
+
|
|
355
|
+
if (interactive) {
|
|
356
|
+
const p = createPrompter();
|
|
357
|
+
try {
|
|
358
|
+
primary = await p.ask('Primary server URL', primary);
|
|
359
|
+
port = await p.ask('Local port this node listens on', port);
|
|
360
|
+
url = await p.ask(
|
|
361
|
+
'URL the primary calls back (advertised)',
|
|
362
|
+
url || `http://${detectLanIp()}:${port}`
|
|
363
|
+
);
|
|
364
|
+
name = await p.ask('Runner name', name);
|
|
365
|
+
browser = await p.askChoice('Default browser', VALID_BROWSERS, browser);
|
|
366
|
+
const newTok = await p.ask('Auth token (Enter to keep)', token);
|
|
367
|
+
token = newTok || token;
|
|
368
|
+
} finally {
|
|
369
|
+
p.close();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!url) url = `http://${detectLanIp()}:${port}`;
|
|
374
|
+
|
|
375
|
+
if (!VALID_BROWSERS.includes(browser)) {
|
|
376
|
+
console.error(`â Invalid browser "${browser}". Choose one of: ${VALID_BROWSERS.join(', ')}`);
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
saveNodeConfig(cwd, {
|
|
381
|
+
id: saved.id ?? null,
|
|
382
|
+
name,
|
|
383
|
+
url,
|
|
384
|
+
token,
|
|
385
|
+
primary,
|
|
386
|
+
browser,
|
|
387
|
+
port,
|
|
388
|
+
pid: saved.pid ?? null
|
|
389
|
+
});
|
|
390
|
+
return { primary, port, browser, token, name, url };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function registerNode({ primary, name, url, token, browser, port }) {
|
|
394
|
+
const { registerWithPrimary, loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
395
|
+
let registeredId = null;
|
|
396
|
+
if (primary) {
|
|
397
|
+
console.log(`đ Registering with primary at ${primary}...`);
|
|
398
|
+
try {
|
|
399
|
+
const { id, reused } = await registerWithPrimary({ primary, name, url, token, browser });
|
|
400
|
+
registeredId = id;
|
|
401
|
+
console.log(reused ? 'â Reusing existing runner on primary\n' : 'â Registered on primary\n');
|
|
402
|
+
} catch (e) {
|
|
403
|
+
console.log(`â ī¸ Could not register with primary: ${e.message}`);
|
|
404
|
+
console.log(' Add it manually using the details below.\n');
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
console.log('âšī¸ No primary set â add this runner manually on your Plum server.\n');
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const card = [
|
|
411
|
+
' ââ Runner details âââââââââââââââââââââââââââ',
|
|
412
|
+
registeredId
|
|
413
|
+
? ` â id: ${registeredId}`
|
|
414
|
+
: ' â id: (assigned when added on the server)',
|
|
415
|
+
` â name: ${name}`,
|
|
416
|
+
` â url: ${url}`,
|
|
417
|
+
` â token: ${token}`,
|
|
418
|
+
` â browser: ${browser}`,
|
|
419
|
+
' âââââââââââââââââââââââââââââââââââââââââââââ'
|
|
420
|
+
].join('\n');
|
|
421
|
+
console.log(card + '\n');
|
|
422
|
+
console.log(
|
|
423
|
+
`âšī¸ The url above must be reachable from the primary. The local port (${port}) is only`
|
|
424
|
+
);
|
|
425
|
+
console.log(' what this node listens on â forward your proxy/domain to it.\n');
|
|
426
|
+
|
|
427
|
+
const cwd = process.cwd();
|
|
428
|
+
saveNodeConfig(cwd, {
|
|
429
|
+
...loadNodeConfig(cwd),
|
|
430
|
+
id: registeredId,
|
|
431
|
+
name,
|
|
432
|
+
url,
|
|
433
|
+
token,
|
|
434
|
+
primary,
|
|
435
|
+
browser,
|
|
436
|
+
port
|
|
437
|
+
});
|
|
438
|
+
return registeredId;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function nodeStart({ reconfig }) {
|
|
442
|
+
const backendDir = path.join(plumRoot, 'backend');
|
|
443
|
+
console.log('--------------------------------------\n');
|
|
444
|
+
console.log('đ Setting up Plum node (runner mode)...\n');
|
|
445
|
+
|
|
446
|
+
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 });
|
|
454
|
+
|
|
455
|
+
console.log('\n--------------------------------------');
|
|
456
|
+
console.log(`đŖ Node "${cfg.name}" running on port ${cfg.port} (Ctrl+C to stop)\n`);
|
|
457
|
+
|
|
458
|
+
const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
459
|
+
const serverPath = path.join(backendDir, 'server.js');
|
|
460
|
+
const child = spawn(process.execPath, [serverPath], {
|
|
461
|
+
cwd: backendDir,
|
|
462
|
+
env: { ...process.env, PLUM_MODE: 'node', NODE_TOKEN: cfg.token, PORT: String(cfg.port) },
|
|
463
|
+
stdio: 'inherit'
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
saveNodeConfig(process.cwd(), { ...loadNodeConfig(process.cwd()), pid: child.pid });
|
|
467
|
+
|
|
468
|
+
child.on('exit', (code) => {
|
|
469
|
+
const c = loadNodeConfig(process.cwd());
|
|
470
|
+
saveNodeConfig(process.cwd(), { ...c, pid: null });
|
|
471
|
+
process.exit(code ?? 0);
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function nodeReconfig() {
|
|
476
|
+
console.log('--------------------------------------\n');
|
|
477
|
+
console.log('âī¸ Reconfiguring Plum node...\n');
|
|
478
|
+
const cfg = await configureNode({ force: true });
|
|
479
|
+
await registerNode(cfg);
|
|
480
|
+
console.log('â
Saved. Run `plum node start` to launch this node.');
|
|
481
|
+
console.log('--------------------------------------\n');
|
|
482
|
+
}
|
|
483
|
+
|
|
137
484
|
/* -----------------------------------------------------
|
|
138
485
|
* Commands
|
|
139
486
|
* Description:
|
|
@@ -273,9 +620,12 @@ switch (command) {
|
|
|
273
620
|
'| `plum run-test @tag` | Run tests matching a tag |',
|
|
274
621
|
'| `plum run-test --parallel N` | Run tests across N parallel workers |',
|
|
275
622
|
'| `plum run-test --browser firefox` | Run in a specific browser (chromium/firefox/webkit) |',
|
|
276
|
-
'| `plum start` | Start the full UI via Docker |',
|
|
623
|
+
'| `plum start` | Start the full UI via Docker (interactive setup) |',
|
|
624
|
+
'| `plum server reconfig` | Change server URL/ports without starting |',
|
|
277
625
|
'| `plum stop` | Stop the server |',
|
|
278
626
|
'| `plum create-step` | Interactively generate a new step definition |',
|
|
627
|
+
'| `plum node start` | Start a runner node and auto-register it with the server |',
|
|
628
|
+
'| `plum node stop` | Stop the runner node started from this folder |',
|
|
279
629
|
'',
|
|
280
630
|
'---',
|
|
281
631
|
'',
|
|
@@ -357,60 +707,15 @@ switch (command) {
|
|
|
357
707
|
console.log('--------------------------------------\n');
|
|
358
708
|
break;
|
|
359
709
|
}
|
|
710
|
+
if (subcommand === 'reconfig') {
|
|
711
|
+
await serverReconfig();
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
360
714
|
// fall through to start for 'plum server start' or 'plum server'
|
|
361
715
|
// intentional fall-through
|
|
362
716
|
|
|
363
717
|
case 'start':
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
console.log('đ Running Plum via Docker Compose...');
|
|
367
|
-
|
|
368
|
-
// Copy .env file from root to backend
|
|
369
|
-
copyEnvFile();
|
|
370
|
-
|
|
371
|
-
// Merge user plugins into backend/package.json before Docker build
|
|
372
|
-
{
|
|
373
|
-
const userPluginsPath = path.join(process.cwd(), 'plum.plugins.json');
|
|
374
|
-
if (fs.existsSync(userPluginsPath)) {
|
|
375
|
-
try {
|
|
376
|
-
const userPlugins = JSON.parse(fs.readFileSync(userPluginsPath, 'utf8'));
|
|
377
|
-
const backendPkgPath = path.join(plumRoot, 'backend', 'package.json');
|
|
378
|
-
const backendPkg = JSON.parse(fs.readFileSync(backendPkgPath, 'utf8'));
|
|
379
|
-
const pluginDeps = userPlugins.dependencies ?? {};
|
|
380
|
-
if (Object.keys(pluginDeps).length > 0) {
|
|
381
|
-
backendPkg.dependencies = { ...backendPkg.dependencies, ...pluginDeps };
|
|
382
|
-
fs.writeFileSync(backendPkgPath, JSON.stringify(backendPkg, null, '\t') + '\n', 'utf8');
|
|
383
|
-
console.log(`đĻ Merged plugins into backend: ${Object.keys(pluginDeps).join(', ')}\n`);
|
|
384
|
-
}
|
|
385
|
-
} catch {
|
|
386
|
-
console.log('â ī¸ Could not read plum.plugins.json. Skipping plugin merge.\n');
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Convert Windows paths to safe format
|
|
392
|
-
const userTestsAbs = path.resolve(process.cwd(), 'tests').replace(/\\/g, '/');
|
|
393
|
-
const userReportsAbs = path.resolve(process.cwd(), 'reports').replace(/\\/g, '/');
|
|
394
|
-
|
|
395
|
-
// Generate docker-compose.override.yml
|
|
396
|
-
// Config is served from the plum installation's backend/config directly via docker-compose.yml
|
|
397
|
-
const overrideYAML = [
|
|
398
|
-
'services:',
|
|
399
|
-
' backend:',
|
|
400
|
-
' volumes:',
|
|
401
|
-
` - "${userReportsAbs}:/app/reports"`,
|
|
402
|
-
` - "${userTestsAbs}:/app/tests"`
|
|
403
|
-
].join('\n');
|
|
404
|
-
|
|
405
|
-
fs.writeFileSync(overrideFilePath, overrideYAML + '\n', 'utf8');
|
|
406
|
-
console.log('â
docker-compose.override.yml written');
|
|
407
|
-
|
|
408
|
-
// Run docker compose (--build picks up any plugin or config changes)
|
|
409
|
-
execSync('docker compose up --build', {
|
|
410
|
-
cwd: plumRoot,
|
|
411
|
-
stdio: 'inherit'
|
|
412
|
-
});
|
|
413
|
-
console.log('--------------------------------------\n');
|
|
718
|
+
await serverStart();
|
|
414
719
|
break;
|
|
415
720
|
|
|
416
721
|
case 'run-test': {
|
|
@@ -500,59 +805,32 @@ switch (command) {
|
|
|
500
805
|
|
|
501
806
|
case 'node': {
|
|
502
807
|
if (subcommand === 'stop') {
|
|
808
|
+
const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
503
809
|
console.log('--------------------------------------\n');
|
|
504
810
|
console.log('đ Stopping Plum node...');
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
811
|
+
const cfg = loadNodeConfig(process.cwd());
|
|
812
|
+
if (cfg.pid) {
|
|
813
|
+
try {
|
|
814
|
+
process.kill(cfg.pid, 'SIGTERM');
|
|
815
|
+
console.log(`â
Stopped node process (pid ${cfg.pid}).\n`);
|
|
816
|
+
} catch {
|
|
817
|
+
console.log('âšī¸ No running node process found (it may already be stopped).\n');
|
|
818
|
+
}
|
|
819
|
+
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
820
|
+
} else {
|
|
821
|
+
console.log('âšī¸ No node started from this folder. If it runs in the foreground,');
|
|
822
|
+
console.log(' press Ctrl+C in its terminal to stop it.\n');
|
|
823
|
+
}
|
|
510
824
|
console.log('--------------------------------------\n');
|
|
511
825
|
break;
|
|
512
826
|
}
|
|
513
827
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const nodeToken = tokenIdx !== -1 ? nodeArgs[tokenIdx + 1] : process.env.NODE_TOKEN || '';
|
|
518
|
-
const primaryIdx = nodeArgs.indexOf('--primary');
|
|
519
|
-
const primaryUrl = primaryIdx !== -1 ? nodeArgs[primaryIdx + 1] : process.env.PRIMARY_URL || '';
|
|
520
|
-
|
|
521
|
-
console.log('--------------------------------------\n');
|
|
522
|
-
console.log('đ Starting Plum node (runner mode)...');
|
|
523
|
-
if (!nodeToken) {
|
|
524
|
-
console.log(
|
|
525
|
-
'â ī¸ No --token provided. The node will accept requests without authentication.\n'
|
|
526
|
-
);
|
|
828
|
+
if (subcommand === 'reconfig') {
|
|
829
|
+
await nodeReconfig();
|
|
830
|
+
break;
|
|
527
831
|
}
|
|
528
832
|
|
|
529
|
-
|
|
530
|
-
const userReportsAbs = path.resolve(process.cwd(), 'reports').replace(/\\/g, '/');
|
|
531
|
-
const nodeOverridePath = path.join(plumRoot, 'docker-compose.node-override.yml');
|
|
532
|
-
|
|
533
|
-
const nodeOverride = [
|
|
534
|
-
'services:',
|
|
535
|
-
' backend:',
|
|
536
|
-
' volumes:',
|
|
537
|
-
` - "${userReportsAbs}:/app/reports"`,
|
|
538
|
-
' environment:',
|
|
539
|
-
` NODE_TOKEN: "${nodeToken}"`,
|
|
540
|
-
` PRIMARY_URL: "${primaryUrl}"`,
|
|
541
|
-
' PLUM_MODE: "node"'
|
|
542
|
-
].join('\n');
|
|
543
|
-
|
|
544
|
-
fs.writeFileSync(nodeOverridePath, nodeOverride + '\n', 'utf8');
|
|
545
|
-
|
|
546
|
-
copyEnvFile();
|
|
547
|
-
|
|
548
|
-
execSync(
|
|
549
|
-
'docker compose -f docker-compose.node.yml -f docker-compose.node-override.yml up --build',
|
|
550
|
-
{
|
|
551
|
-
cwd: plumRoot,
|
|
552
|
-
stdio: 'inherit'
|
|
553
|
-
}
|
|
554
|
-
);
|
|
555
|
-
console.log('--------------------------------------\n');
|
|
833
|
+
await nodeStart({ reconfig: false });
|
|
556
834
|
break;
|
|
557
835
|
}
|
|
558
836
|
|
|
@@ -573,12 +851,26 @@ switch (command) {
|
|
|
573
851
|
console.log('--------------------------------------\n');
|
|
574
852
|
console.log('Usage: plum <command>\n');
|
|
575
853
|
console.log(' init Set up a new Plum project');
|
|
576
|
-
console.log(' server start Start the full UI stack
|
|
854
|
+
console.log(' server start Start the full UI stack (interactive; alias: plum start)');
|
|
855
|
+
console.log(' --base-url <url> App URL to test (skips the prompt)');
|
|
856
|
+
console.log(' --headless <bool> Run browsers headless (true/false)');
|
|
857
|
+
console.log(' --backend-port <n> Host port for the backend/API (default: 3001)');
|
|
858
|
+
console.log(' --frontend-port <n> Host port for the UI (default: 5173)');
|
|
859
|
+
console.log(' --primary-url <url> Public URL node operators point --primary at');
|
|
860
|
+
console.log(' server reconfig Re-enter server settings without starting');
|
|
577
861
|
console.log(' server stop Stop the server (alias: plum stop)');
|
|
578
|
-
console.log(' node start Start a runner node (
|
|
579
|
-
console.log(' --
|
|
580
|
-
console.log(' --
|
|
581
|
-
console.log(
|
|
862
|
+
console.log(' node start Start a runner node (interactive) and register it');
|
|
863
|
+
console.log(' --primary <url> Primary Plum server to auto-register with (prints the id)');
|
|
864
|
+
console.log(' --url <url> Address the primary calls back (default: <lan-ip>:<port>;');
|
|
865
|
+
console.log(
|
|
866
|
+
' pass a domain like https://node1.example behind a TLS proxy)'
|
|
867
|
+
);
|
|
868
|
+
console.log(' --port <n> Local HTTP port the node listens on (default: 3001)');
|
|
869
|
+
console.log(' --token <secret> Auth token (auto-generated + saved if omitted)');
|
|
870
|
+
console.log(' --name <name> Runner name shown on the primary (default: node-<rand>)');
|
|
871
|
+
console.log(' --browser <name> chromium | firefox | webkit (default: chromium)');
|
|
872
|
+
console.log(' node reconfig Re-enter node settings + re-register, without starting');
|
|
873
|
+
console.log(' node stop Stop the runner node started from this folder');
|
|
582
874
|
console.log(' run-test Run tests locally without Docker');
|
|
583
875
|
console.log(' @tag Run only tests matching a tag');
|
|
584
876
|
console.log(' --parallel <n> Run across n parallel workers');
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { fileURLToPath } from 'url';
|
|
21
|
+
import fse from 'fs-extra';
|
|
22
|
+
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
const plumRoot = path.resolve(__dirname, '..');
|
|
26
|
+
const scaffoldPath = path.join(plumRoot, 'backend', '_scaffold');
|
|
27
|
+
const testsPath = path.join(plumRoot, 'backend', 'tests');
|
|
28
|
+
|
|
29
|
+
if (fs.existsSync(testsPath)) {
|
|
30
|
+
console.log('â ī¸ `tests/` already exists. Skipping scaffold copy.\n');
|
|
31
|
+
} else {
|
|
32
|
+
fse.copySync(scaffoldPath, testsPath);
|
|
33
|
+
console.log('â
`tests/` initialized from scaffold.\n');
|
|
34
|
+
}
|