plum-e2e 2.5.8 → 2.5.10
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/backend/lib/globalRegistry.js +67 -0
- package/backend/mcp/server.js +67 -0
- package/bin/plum.js +72 -24
- package/package.json +1 -1
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
* `.plum-server.json`/`.plum-node.json` live in whatever directory the user
|
|
20
|
+
* happened to run `plum server start`/`plum node start` from. `plum update`
|
|
21
|
+
* needs to find and restart those installs later regardless of the cwd it's
|
|
22
|
+
* invoked from — this is the one place, independent of any project
|
|
23
|
+
* directory, that remembers where they are.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const REGISTRY_DIR = path.join(os.homedir(), '.plum');
|
|
31
|
+
const REGISTRY_PATH = path.join(REGISTRY_DIR, 'installs.json');
|
|
32
|
+
|
|
33
|
+
function load() {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(REGISTRY_PATH, 'utf8'));
|
|
36
|
+
} catch {
|
|
37
|
+
return { server: [], node: [] };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save(data) {
|
|
42
|
+
fs.mkdirSync(REGISTRY_DIR, { recursive: true });
|
|
43
|
+
fs.writeFileSync(REGISTRY_PATH, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Records `dir` as a known `type` ('server' | 'node') install location. */
|
|
47
|
+
function registerInstall(type, dir) {
|
|
48
|
+
const data = load();
|
|
49
|
+
if (!data[type]) data[type] = [];
|
|
50
|
+
if (!data[type].includes(dir)) {
|
|
51
|
+
data[type].push(dir);
|
|
52
|
+
save(data);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Known install dirs for `type`, pruned of any that no longer exist on disk. */
|
|
57
|
+
function getInstalls(type) {
|
|
58
|
+
const data = load();
|
|
59
|
+
const dirs = (data[type] || []).filter((d) => fs.existsSync(d));
|
|
60
|
+
if (dirs.length !== (data[type] || []).length) {
|
|
61
|
+
data[type] = dirs;
|
|
62
|
+
save(data);
|
|
63
|
+
}
|
|
64
|
+
return dirs;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { REGISTRY_PATH, registerInstall, getInstalls };
|
package/backend/mcp/server.js
CHANGED
|
@@ -67,6 +67,7 @@ async function api(method, path, body) {
|
|
|
67
67
|
const get = (path) => api('GET', path);
|
|
68
68
|
const post = (path, body) => api('POST', path, body);
|
|
69
69
|
const put = (path, body) => api('PUT', path, body);
|
|
70
|
+
const del = (path) => api('DELETE', path);
|
|
70
71
|
|
|
71
72
|
// ---------------------------------------------------------------------------
|
|
72
73
|
// Polling helper for test runs
|
|
@@ -176,6 +177,33 @@ server.tool(
|
|
|
176
177
|
}
|
|
177
178
|
);
|
|
178
179
|
|
|
180
|
+
server.tool(
|
|
181
|
+
'update_test_suite',
|
|
182
|
+
"Update a test suite's name, description, or priority. Only the fields provided are changed.",
|
|
183
|
+
{
|
|
184
|
+
suiteId: z.string().describe('UUID of the suite to update'),
|
|
185
|
+
name: z.string().min(1).optional(),
|
|
186
|
+
description: z.string().optional(),
|
|
187
|
+
priority: z.enum(['Critical', 'High', 'Medium', 'Low']).optional()
|
|
188
|
+
},
|
|
189
|
+
async ({ suiteId, name, description, priority }) => {
|
|
190
|
+
const data = await put(`/test-suites/${suiteId}`, { name, description, priority });
|
|
191
|
+
return { content: [{ type: 'text', text: JSON.stringify(data.suite, null, 2) }] };
|
|
192
|
+
}
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
server.tool(
|
|
196
|
+
'delete_test_suite',
|
|
197
|
+
'Permanently delete a test suite and all of its test cases. This cannot be undone.',
|
|
198
|
+
{
|
|
199
|
+
suiteId: z.string().describe('UUID of the suite to delete')
|
|
200
|
+
},
|
|
201
|
+
async ({ suiteId }) => {
|
|
202
|
+
await del(`/test-suites/${suiteId}`);
|
|
203
|
+
return { content: [{ type: 'text', text: `Suite ${suiteId} deleted.` }] };
|
|
204
|
+
}
|
|
205
|
+
);
|
|
206
|
+
|
|
179
207
|
// -- Test Repository: Cases -------------------------------------------------
|
|
180
208
|
|
|
181
209
|
server.tool(
|
|
@@ -193,6 +221,45 @@ server.tool(
|
|
|
193
221
|
}
|
|
194
222
|
);
|
|
195
223
|
|
|
224
|
+
server.tool(
|
|
225
|
+
'get_test_case',
|
|
226
|
+
'Get a test case by ID, including its manual steps and recent execution history.',
|
|
227
|
+
{
|
|
228
|
+
caseId: z.string().describe('UUID of the test case')
|
|
229
|
+
},
|
|
230
|
+
async ({ caseId }) => {
|
|
231
|
+
const data = await get(`/test-cases/${caseId}`);
|
|
232
|
+
return { content: [{ type: 'text', text: JSON.stringify(data.testCase, null, 2) }] };
|
|
233
|
+
}
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
server.tool(
|
|
237
|
+
'update_test_case',
|
|
238
|
+
"Update a test case's title, description, or priority. Only the fields provided are changed.",
|
|
239
|
+
{
|
|
240
|
+
caseId: z.string().describe('UUID of the test case to update'),
|
|
241
|
+
title: z.string().min(1).optional(),
|
|
242
|
+
description: z.string().optional(),
|
|
243
|
+
priority: z.enum(['Critical', 'High', 'Medium', 'Low']).optional()
|
|
244
|
+
},
|
|
245
|
+
async ({ caseId, title, description, priority }) => {
|
|
246
|
+
const data = await put(`/test-cases/${caseId}`, { title, description, priority });
|
|
247
|
+
return { content: [{ type: 'text', text: JSON.stringify(data.testCase, null, 2) }] };
|
|
248
|
+
}
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
server.tool(
|
|
252
|
+
'delete_test_case',
|
|
253
|
+
'Permanently delete a test case and its steps. This cannot be undone.',
|
|
254
|
+
{
|
|
255
|
+
caseId: z.string().describe('UUID of the test case to delete')
|
|
256
|
+
},
|
|
257
|
+
async ({ caseId }) => {
|
|
258
|
+
await del(`/test-cases/${caseId}`);
|
|
259
|
+
return { content: [{ type: 'text', text: `Test case ${caseId} deleted.` }] };
|
|
260
|
+
}
|
|
261
|
+
);
|
|
262
|
+
|
|
196
263
|
server.tool(
|
|
197
264
|
'set_test_steps',
|
|
198
265
|
'Set (replace) the manual test steps for a test case. Each step has an action, optional test data, and optional expected output.',
|
package/bin/plum.js
CHANGED
|
@@ -138,6 +138,7 @@ const backendLib = path.join(plumRoot, 'backend', 'lib');
|
|
|
138
138
|
const serverConfigLib = () => require(path.join(backendLib, 'serverConfig.js'));
|
|
139
139
|
const nodeRegisterLib = () => require(path.join(backendLib, 'nodeRegister.js'));
|
|
140
140
|
const runnerProcessLib = () => require(path.join(backendLib, 'runnerProcess.js'));
|
|
141
|
+
const globalRegistryLib = () => require(path.join(backendLib, 'globalRegistry.js'));
|
|
141
142
|
|
|
142
143
|
/* -----------------------------------------------------
|
|
143
144
|
* Interactive prompts
|
|
@@ -254,6 +255,7 @@ async function configureServer({ force }) {
|
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
saveServerConfig(cwd, cfg);
|
|
258
|
+
globalRegistryLib().registerInstall('server', cwd);
|
|
257
259
|
return cfg;
|
|
258
260
|
}
|
|
259
261
|
|
|
@@ -484,9 +486,15 @@ function npmInstallLatestWithRetry() {
|
|
|
484
486
|
return false;
|
|
485
487
|
}
|
|
486
488
|
|
|
489
|
+
function readPlumVersion() {
|
|
490
|
+
return JSON.parse(fs.readFileSync(path.join(plumRoot, 'package.json'), 'utf8')).version;
|
|
491
|
+
}
|
|
492
|
+
|
|
487
493
|
async function serverUpdate() {
|
|
488
494
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Update ')));
|
|
489
|
-
|
|
495
|
+
|
|
496
|
+
const fromVersion = readPlumVersion();
|
|
497
|
+
clack.log.step(`Fetching latest Plum version… (currently ${fromVersion})`);
|
|
490
498
|
if (!npmInstallLatestWithRetry()) {
|
|
491
499
|
clack.log.error(
|
|
492
500
|
`Failed to install the latest version after ${NPM_INSTALL_RETRIES} attempts. Try again shortly, or run "npm install -g plum-e2e@latest" manually to see the full error.`
|
|
@@ -495,39 +503,61 @@ async function serverUpdate() {
|
|
|
495
503
|
process.exitCode = 1;
|
|
496
504
|
return;
|
|
497
505
|
}
|
|
498
|
-
clack.log.success('Plum CLI updated.');
|
|
499
506
|
|
|
500
|
-
|
|
501
|
-
const
|
|
507
|
+
// Re-read from disk (not require-cached) so this reflects what npm just installed.
|
|
508
|
+
const toVersion = readPlumVersion();
|
|
509
|
+
clack.log.success(`Plum CLI updated: ${fromVersion} → ${toVersion}`);
|
|
502
510
|
|
|
511
|
+
// Every install registers its directory here when configured (see
|
|
512
|
+
// configureServer/configureNode), so this finds them regardless of the cwd
|
|
513
|
+
// `plum update` happens to be run from.
|
|
514
|
+
const { getInstalls } = globalRegistryLib();
|
|
503
515
|
const { loadNodeConfig } = nodeRegisterLib();
|
|
504
516
|
const { loadRegistry, isAlive } = runnerProcessLib();
|
|
505
|
-
const nodeCfg = loadNodeConfig(process.cwd());
|
|
506
|
-
const registry = loadRegistry();
|
|
507
|
-
const nodeRunning = !!(
|
|
508
|
-
nodeCfg.id &&
|
|
509
|
-
registry[String(nodeCfg.id)]?.pid &&
|
|
510
|
-
isAlive(registry[String(nodeCfg.id)].pid)
|
|
511
|
-
);
|
|
512
517
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
518
|
+
let restartedAnything = false;
|
|
519
|
+
|
|
520
|
+
// Re-exec `plum` as a fresh process (cwd set to each install dir) for the
|
|
521
|
+
// restart steps rather than calling serverRestart()/nodeRestart() directly —
|
|
522
|
+
// this same process already loaded the OLD code into memory before npm
|
|
523
|
+
// install ran above, so calling them in-process would rebuild using stale
|
|
524
|
+
// logic no matter how new the just-installed files on disk actually are.
|
|
525
|
+
for (const dir of getInstalls('server')) {
|
|
526
|
+
if (!fs.existsSync(path.join(dir, '.plum-server.json'))) continue;
|
|
527
|
+
clack.log.step(`Rebuilding server at ${dir}…`);
|
|
528
|
+
try {
|
|
529
|
+
execSync('plum server restart', { stdio: 'inherit', cwd: dir });
|
|
530
|
+
restartedAnything = true;
|
|
531
|
+
} catch (e) {
|
|
532
|
+
clack.log.warn(`Could not restart server at ${dir}: ${e.message}`);
|
|
533
|
+
}
|
|
521
534
|
}
|
|
522
535
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
536
|
+
const registry = loadRegistry();
|
|
537
|
+
for (const dir of getInstalls('node')) {
|
|
538
|
+
const nodeCfg = loadNodeConfig(dir);
|
|
539
|
+
const running = !!(
|
|
540
|
+
nodeCfg.id &&
|
|
541
|
+
registry[String(nodeCfg.id)]?.pid &&
|
|
542
|
+
isAlive(registry[String(nodeCfg.id)].pid)
|
|
543
|
+
);
|
|
544
|
+
if (!running) continue;
|
|
545
|
+
clack.log.step(`Restarting node runner at ${dir}…`);
|
|
546
|
+
try {
|
|
547
|
+
execSync('plum node restart', { stdio: 'inherit', cwd: dir });
|
|
548
|
+
restartedAnything = true;
|
|
549
|
+
} catch (e) {
|
|
550
|
+
clack.log.warn(`Could not restart node at ${dir}: ${e.message}`);
|
|
551
|
+
}
|
|
526
552
|
}
|
|
527
553
|
|
|
528
|
-
if (!
|
|
529
|
-
clack.
|
|
554
|
+
if (!restartedAnything) {
|
|
555
|
+
clack.log.info(
|
|
556
|
+
'No running server or node found — run `plum server start` or `plum node start` when ready.'
|
|
557
|
+
);
|
|
530
558
|
}
|
|
559
|
+
|
|
560
|
+
clack.outro(pc.green(`Plum updated: ${fromVersion} → ${toVersion}`));
|
|
531
561
|
}
|
|
532
562
|
|
|
533
563
|
async function serverReconfig() {
|
|
@@ -610,6 +640,7 @@ async function configureNode({ force }) {
|
|
|
610
640
|
port,
|
|
611
641
|
pid: saved.pid ?? null
|
|
612
642
|
});
|
|
643
|
+
globalRegistryLib().registerInstall('node', cwd);
|
|
613
644
|
return { primary, port, browser, token, name, url };
|
|
614
645
|
}
|
|
615
646
|
|
|
@@ -665,6 +696,23 @@ async function nodeStart({ reconfig }) {
|
|
|
665
696
|
const backendDir = path.join(plumRoot, 'backend');
|
|
666
697
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Runner ')));
|
|
667
698
|
|
|
699
|
+
const { loadNodeConfig } = nodeRegisterLib();
|
|
700
|
+
const { statusOf } = runnerProcessLib();
|
|
701
|
+
const existing = loadNodeConfig(process.cwd());
|
|
702
|
+
|
|
703
|
+
// Re-running `node start` on an already-running node used to spawn a second
|
|
704
|
+
// process on the same port (orphaning the first) and re-register a duplicate
|
|
705
|
+
// runner on the primary. Route to the same menu this command ends on anyway
|
|
706
|
+
// instead of repeating the whole configure/register/spawn dance.
|
|
707
|
+
if (!reconfig && existing.id && statusOf(String(existing.id)) === 'running') {
|
|
708
|
+
clack.log.info(
|
|
709
|
+
`Node "${existing.name ?? existing.id}" is already running from this folder — opening the runner menu instead of starting a new one.`
|
|
710
|
+
);
|
|
711
|
+
await openManageRunnersMenu(existing.primary);
|
|
712
|
+
clack.outro(`Manage runners anytime: ${pc.cyan('plum manage-runners')}`);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
|
|
668
716
|
const cfg = await configureNode({ force: reconfig });
|
|
669
717
|
const registeredId = await registerNode(cfg);
|
|
670
718
|
|