plum-e2e 2.3.0 → 2.4.0

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 CHANGED
@@ -78,21 +78,21 @@ Full documentation is available at:
78
78
 
79
79
  ## Command Reference
80
80
 
81
- | Command | Description |
82
- | ----------------------------- | -------------------------------------------------------- |
83
- | `plum init` | Initialize a new project in the current folder |
84
- | `plum server start` | Start the full UI stack via Docker (alias: `plum start`) |
85
- | `plum server stop` | Stop the server and preserve data (alias: `plum stop`) |
86
- | `plum server reconfig` | Re-enter server settings without starting |
87
- | `plum run-test` | Run all tests locally without Docker |
88
- | `plum run-test @tag` | Run tests matching a tag |
89
- | `plum run-test --parallel N` | Run tests across N parallel workers |
90
- | `plum run-test --browser <b>` | Run in `chromium` (default) or `firefox` |
91
- | `plum create-step` | Interactively scaffold a new step definition |
92
- | `plum node start` | Configure, register, and start a runner node |
93
- | `plum node stop` | Stop the runner node started from this folder |
94
- | `plum node reconfig` | Re-enter node settings and re-register |
95
- | `plum manage-runners` | Open the interactive runner management menu |
81
+ | Command | Description |
82
+ | ----------------------------- | ------------------------------------------------------------------ |
83
+ | `plum init` | Initialize a new project in the current folder |
84
+ | `plum server start` | Start the full UI stack via Docker (alias: `plum start`) |
85
+ | `plum server stop` | Stop the server and preserve data (alias: `plum stop`) |
86
+ | `plum server reconfig` | Re-enter server settings without starting |
87
+ | `plum run-test` | Run all tests locally without Docker |
88
+ | `plum run-test @tag` | Run tests matching a tag |
89
+ | `plum run-test --parallel N` | Run tests across N parallel workers |
90
+ | `plum run-test --browser <b>` | Run in `chromium` (default) or `firefox` |
91
+ | `plum create-step` | Interactively scaffold a new step definition |
92
+ | `plum node start` | Set up connectivity, start a runner node, and open the runner menu |
93
+ | `plum node stop` | Stop the runner node started from this folder |
94
+ | `plum node reconfig` | Re-enter node settings and re-register |
95
+ | `plum manage-runners` | Open the interactive runner management menu |
96
96
 
97
97
  ---
98
98
 
package/backend/app.js CHANGED
@@ -44,6 +44,7 @@ if (process.env.PLUM_MODE !== 'node') {
44
44
  app.use('/test-suites', require('./routes/test-suites.routes'));
45
45
  app.use('/test-cases', require('./routes/test-cases.routes'));
46
46
  app.use('/test-runs', require('./routes/test-runs.routes'));
47
+ app.use('/trigger', require('./routes/trigger.routes'));
47
48
  }
48
49
 
49
50
  module.exports = app;
@@ -23,7 +23,8 @@
23
23
  */
24
24
  const TRIGGER_TYPE = Object.freeze({
25
25
  MANUAL: 'manual-trigger',
26
- CLI: 'command-line-trigger'
26
+ CLI: 'command-line-trigger',
27
+ MCP: 'mcp-trigger'
27
28
  });
28
29
 
29
30
  /**
@@ -43,6 +44,7 @@ const BUILT_IN_RUNNER_ID = 'built-in';
43
44
  const NON_SCHEDULED_TRIGGERS = new Set([
44
45
  TRIGGER_TYPE.MANUAL,
45
46
  TRIGGER_TYPE.CLI,
47
+ TRIGGER_TYPE.MCP,
46
48
  TRIGGER_REMOTE,
47
49
  'undefined'
48
50
  ]);
@@ -25,18 +25,14 @@
25
25
 
26
26
  const fs = require('fs');
27
27
  const path = require('path');
28
- const { detectLanIp } = require('./nodeRegister');
29
28
 
30
29
  const CONFIG_FILENAME = '.plum-server.json';
31
30
 
32
31
  function defaults() {
33
- const backendPort = '3001';
34
32
  return {
35
- baseUrl: 'https://www.saucedemo.com/v1/',
36
33
  headless: false,
37
- backendPort,
38
- frontendPort: '5173',
39
- primaryPublicUrl: `http://${detectLanIp()}:${backendPort}`
34
+ backendPort: '3001',
35
+ frontendPort: '5173'
40
36
  };
41
37
  }
42
38
 
@@ -44,13 +40,11 @@ function configPath(dir) {
44
40
  return path.join(dir, CONFIG_FILENAME);
45
41
  }
46
42
 
47
- /** Seeds baseUrl/headless from an existing .env so first-run prompts reflect it. */
43
+ /** Seeds headless from an existing .env so the first-run prompt reflects it. */
48
44
  function readEnvSeed(dir) {
49
45
  try {
50
46
  const txt = fs.readFileSync(path.join(dir, '.env'), 'utf8');
51
47
  const seed = {};
52
- const baseUrl = txt.match(/^BASE_URL=(.*)$/m);
53
- if (baseUrl) seed.baseUrl = baseUrl[1].trim();
54
48
  const headless = txt.match(/^IS_HEADLESS=(.*)$/m);
55
49
  if (headless) seed.headless = headless[1].trim() === 'true';
56
50
  return seed;
@@ -69,13 +63,28 @@ function loadServerConfig(dir) {
69
63
  }
70
64
 
71
65
  function saveServerConfig(dir, cfg) {
72
- fs.writeFileSync(configPath(dir), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
66
+ const { headless, backendPort, frontendPort } = cfg;
67
+ fs.writeFileSync(
68
+ configPath(dir),
69
+ JSON.stringify({ headless, backendPort, frontendPort }, null, 2) + '\n',
70
+ 'utf8'
71
+ );
73
72
  }
74
73
 
75
- /** Writes the root .env consumed by the backend/tests from the server config. */
76
- function writeEnvFile(dir, { baseUrl, headless }) {
77
- const content = `BASE_URL=${baseUrl}\nIS_HEADLESS=${headless ? 'true' : 'false'}\n`;
78
- fs.writeFileSync(path.join(dir, '.env'), content, 'utf8');
74
+ /** Updates IS_HEADLESS in the root .env, preserving all other entries. */
75
+ function writeEnvFile(dir, { headless }) {
76
+ const envPath = path.join(dir, '.env');
77
+ let content = '';
78
+ try {
79
+ content = fs.readFileSync(envPath, 'utf8');
80
+ } catch {}
81
+ const line = `IS_HEADLESS=${headless ? 'true' : 'false'}`;
82
+ if (/^IS_HEADLESS=/m.test(content)) {
83
+ content = content.replace(/^IS_HEADLESS=.*/m, line);
84
+ } else {
85
+ content = content.endsWith('\n') ? content + line + '\n' : content + '\n' + line + '\n';
86
+ }
87
+ fs.writeFileSync(envPath, content, 'utf8');
79
88
  }
80
89
 
81
90
  /**
@@ -0,0 +1,385 @@
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 MCP Server
20
+ *
21
+ * Exposes Plum's Test Repository and test runner to Claude over stdio.
22
+ *
23
+ * Configuration (env vars):
24
+ * PLUM_API_URL — Plum backend base URL (default: http://localhost:3001)
25
+ * PLUM_API_KEY — Must match PLUM_MCP_KEY set in backend/.env
26
+ */
27
+
28
+ const path = require('path');
29
+
30
+ // Use absolute paths to bypass the SDK's wildcard export mapping
31
+ const sdkCjs = path.resolve(
32
+ __dirname,
33
+ '..',
34
+ 'node_modules',
35
+ '@modelcontextprotocol',
36
+ 'sdk',
37
+ 'dist',
38
+ 'cjs'
39
+ );
40
+ const { McpServer } = require(path.join(sdkCjs, 'server', 'mcp.js'));
41
+ const { StdioServerTransport } = require(path.join(sdkCjs, 'server', 'stdio.js'));
42
+ const { z } = require('zod');
43
+
44
+ const API_URL = (process.env.PLUM_API_URL || 'http://localhost:3001').replace(/\/$/, '');
45
+ const API_KEY = process.env.PLUM_API_KEY || '';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // HTTP helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ async function api(method, path, body) {
52
+ const res = await fetch(`${API_URL}${path}`, {
53
+ method,
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ Authorization: `ApiKey ${API_KEY}`
57
+ },
58
+ body: body !== undefined ? JSON.stringify(body) : undefined
59
+ });
60
+ const json = await res.json().catch(() => ({}));
61
+ if (!res.ok) {
62
+ throw new Error(json.error || `HTTP ${res.status} ${method} ${path}`);
63
+ }
64
+ return json;
65
+ }
66
+
67
+ const get = (path) => api('GET', path);
68
+ const post = (path, body) => api('POST', path, body);
69
+ const put = (path, body) => api('PUT', path, body);
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Polling helper for test runs
73
+ // ---------------------------------------------------------------------------
74
+
75
+ async function pollJob(jobId, { maxMs = 600_000, intervalMs = 5_000 } = {}) {
76
+ const deadline = Date.now() + maxMs;
77
+ while (Date.now() < deadline) {
78
+ await new Promise((r) => setTimeout(r, intervalMs));
79
+ const job = await get(`/trigger/${jobId}`);
80
+ if (job.status !== 'running') return job;
81
+ }
82
+ return { status: 'timeout', jobId };
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Report summary helper
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function summariseReport(report) {
90
+ const features = report.content?.features ?? [];
91
+ const allScenarios = features.flatMap((f) => f.scenarios ?? []);
92
+ const total = allScenarios.length;
93
+ const passed = allScenarios.filter((s) => s.status === 'passed').length;
94
+ const failed = allScenarios.filter((s) => s.status === 'failed').length;
95
+
96
+ const failedDetails = allScenarios
97
+ .filter((s) => s.status === 'failed')
98
+ .map((s) => {
99
+ const failedStep = (s.steps ?? []).find((st) => st.status === 'failed');
100
+ return {
101
+ scenario: s.name,
102
+ feature: features.find((f) => (f.scenarios ?? []).includes(s))?.name ?? '',
103
+ failedStep: failedStep?.name ?? '',
104
+ error: failedStep?.error ?? ''
105
+ };
106
+ });
107
+
108
+ return {
109
+ id: report.id,
110
+ status: report.status,
111
+ browser: report.browser,
112
+ tags: report.tags,
113
+ createdAt: report.createdAt,
114
+ summary: { total, passed, failed },
115
+ failures: failedDetails
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Server setup
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const server = new McpServer({
124
+ name: 'plum',
125
+ version: '1.0.0'
126
+ });
127
+
128
+ // -- Test Repository: Suites -------------------------------------------------
129
+
130
+ server.tool(
131
+ 'list_test_suites',
132
+ 'List all test suites in the Plum Test Repository.',
133
+ {
134
+ page: z.number().int().positive().optional().describe('Page number (default 1)'),
135
+ limit: z.number().int().positive().max(100).optional().describe('Results per page (default 20)')
136
+ },
137
+ async ({ page = 1, limit = 20 }) => {
138
+ const data = await get(`/test-suites?page=${page}&limit=${limit}`);
139
+ return {
140
+ content: [
141
+ {
142
+ type: 'text',
143
+ text: JSON.stringify(data, null, 2)
144
+ }
145
+ ]
146
+ };
147
+ }
148
+ );
149
+
150
+ server.tool(
151
+ 'get_test_suite',
152
+ 'Get a test suite by ID, including all its test cases.',
153
+ {
154
+ suiteId: z.string().describe('The suite ID (e.g. the database UUID, not the displayId)')
155
+ },
156
+ async ({ suiteId }) => {
157
+ const data = await get(`/test-suites/${suiteId}`);
158
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
159
+ }
160
+ );
161
+
162
+ server.tool(
163
+ 'create_test_suite',
164
+ 'Create a new test suite in the Plum Test Repository.',
165
+ {
166
+ name: z.string().min(1).describe('Suite name, e.g. "User Authentication"'),
167
+ description: z.string().optional().describe('What this suite covers'),
168
+ priority: z
169
+ .enum(['Critical', 'High', 'Medium', 'Low'])
170
+ .optional()
171
+ .describe('Suite priority (default Medium)')
172
+ },
173
+ async ({ name, description, priority }) => {
174
+ const data = await post('/test-suites', { name, description, priority });
175
+ return { content: [{ type: 'text', text: JSON.stringify(data.suite, null, 2) }] };
176
+ }
177
+ );
178
+
179
+ // -- Test Repository: Cases -------------------------------------------------
180
+
181
+ server.tool(
182
+ 'create_test_case',
183
+ 'Create a test case inside an existing test suite.',
184
+ {
185
+ suiteId: z.string().describe('UUID of the parent test suite'),
186
+ title: z.string().min(1).describe('Case title, e.g. "User can log in with valid credentials"'),
187
+ description: z.string().optional().describe('Scope, assumptions, or edge cases'),
188
+ priority: z.enum(['Critical', 'High', 'Medium', 'Low']).optional()
189
+ },
190
+ async ({ suiteId, title, description, priority }) => {
191
+ const data = await post('/test-cases', { suiteId, title, description, priority });
192
+ return { content: [{ type: 'text', text: JSON.stringify(data.testCase, null, 2) }] };
193
+ }
194
+ );
195
+
196
+ server.tool(
197
+ 'set_test_steps',
198
+ 'Set (replace) the manual test steps for a test case. Each step has an action, optional test data, and optional expected output.',
199
+ {
200
+ caseId: z.string().describe('UUID of the test case'),
201
+ steps: z
202
+ .array(
203
+ z.object({
204
+ action: z.string().describe('What the tester does'),
205
+ testData: z.string().optional().describe('Specific input or data to use'),
206
+ expectedOutput: z.string().optional().describe('What should happen')
207
+ })
208
+ )
209
+ .describe('Ordered list of steps. Replaces any existing steps.')
210
+ },
211
+ async ({ caseId, steps }) => {
212
+ const data = await put(`/test-cases/${caseId}/steps`, { steps });
213
+ return { content: [{ type: 'text', text: JSON.stringify(data.steps, null, 2) }] };
214
+ }
215
+ );
216
+
217
+ // -- Test Runner ------------------------------------------------------------
218
+
219
+ server.tool(
220
+ 'run_tests',
221
+ [
222
+ 'Trigger a Plum test run and wait for results. Returns a pass/fail summary.',
223
+ '',
224
+ 'For PR testing: pass baseUrl to override the default BASE_URL for this run only.',
225
+ 'If the app runs on localhost, use host.docker.internal instead (e.g. http://host.docker.internal:3000)',
226
+ 'so the Plum Docker container can reach it.',
227
+ '',
228
+ 'Tag filter examples: "@login" "@TC-001 or @TC-002" "@smoke and not @slow"',
229
+ 'Leave tag blank to run all tests.'
230
+ ].join('\n'),
231
+ {
232
+ tag: z
233
+ .string()
234
+ .optional()
235
+ .describe('Cucumber tag expression to filter scenarios. Leave blank to run all.'),
236
+ browser: z
237
+ .enum(['chromium', 'firefox'])
238
+ .optional()
239
+ .describe('Browser to run tests in (default chromium)'),
240
+ workers: z
241
+ .number()
242
+ .int()
243
+ .positive()
244
+ .max(10)
245
+ .optional()
246
+ .describe('Number of parallel workers (default 1)'),
247
+ baseUrl: z
248
+ .string()
249
+ .url()
250
+ .optional()
251
+ .describe(
252
+ 'Override the app URL for this run only. Use host.docker.internal instead of localhost.'
253
+ ),
254
+ testRunId: z
255
+ .string()
256
+ .optional()
257
+ .describe(
258
+ 'Link results to a Plum Test Run. Automated entries will be marked pass/fail automatically.'
259
+ )
260
+ },
261
+ async ({ tag, browser, workers, baseUrl, testRunId }) => {
262
+ const { jobId } = await post('/trigger', { tag, browser, workers, baseUrl, testRunId });
263
+
264
+ const job = await pollJob(jobId);
265
+
266
+ if (job.status === 'timeout') {
267
+ return {
268
+ content: [
269
+ {
270
+ type: 'text',
271
+ text: `Tests are still running after 10 minutes. Job ID: ${jobId}\nUse get_run_status("${jobId}") to check later.`
272
+ }
273
+ ]
274
+ };
275
+ }
276
+
277
+ if (!job.reportId) {
278
+ return {
279
+ content: [
280
+ {
281
+ type: 'text',
282
+ text: `Tests finished with exit code ${job.exitCode} but no report was found. The run may have been cancelled or produced no output.`
283
+ }
284
+ ]
285
+ };
286
+ }
287
+
288
+ const report = await get(`/reports/${job.reportId}`);
289
+ const summary = summariseReport(report.report ?? report);
290
+
291
+ const lines = [
292
+ `Status: ${summary.status?.toUpperCase()}`,
293
+ `Scenarios: ${summary.summary.total} total, ${summary.summary.passed} passed, ${summary.summary.failed} failed`,
294
+ `Browser: ${summary.browser}`,
295
+ `Tags: ${summary.tags || 'all tests'}`,
296
+ ''
297
+ ];
298
+
299
+ if (summary.failures.length > 0) {
300
+ lines.push('Failed scenarios:');
301
+ for (const f of summary.failures) {
302
+ lines.push(` ✗ ${f.feature} › ${f.scenario}`);
303
+ if (f.failedStep) lines.push(` Step: ${f.failedStep}`);
304
+ if (f.error) lines.push(` Error: ${f.error.split('\n')[0]}`);
305
+ }
306
+ } else {
307
+ lines.push('All scenarios passed.');
308
+ }
309
+
310
+ lines.push('', `Report ID: ${summary.id}`);
311
+
312
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
313
+ }
314
+ );
315
+
316
+ server.tool(
317
+ 'get_run_status',
318
+ 'Check the status of a test run that was started with run_tests. Returns status, exit code, and report ID when done.',
319
+ {
320
+ jobId: z.string().uuid().describe('Job ID returned by run_tests')
321
+ },
322
+ async ({ jobId }) => {
323
+ const job = await get(`/trigger/${jobId}`);
324
+ return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }] };
325
+ }
326
+ );
327
+
328
+ // -- Reports ----------------------------------------------------------------
329
+
330
+ server.tool(
331
+ 'list_reports',
332
+ 'List recent Plum test reports.',
333
+ {
334
+ limit: z.number().int().positive().max(50).optional().describe('Number of reports (default 10)')
335
+ },
336
+ async ({ limit = 10 }) => {
337
+ const data = await get(`/reports?limit=${limit}`);
338
+ return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
339
+ }
340
+ );
341
+
342
+ server.tool(
343
+ 'get_report_summary',
344
+ 'Get a human-readable summary of a Plum test report, including a list of failed scenarios.',
345
+ {
346
+ reportId: z.number().int().describe('Numeric report ID')
347
+ },
348
+ async ({ reportId }) => {
349
+ const data = await get(`/reports/${reportId}`);
350
+ const summary = summariseReport(data.report ?? data);
351
+ const lines = [
352
+ `Report #${summary.id} — ${summary.status?.toUpperCase()}`,
353
+ `Date: ${summary.createdAt}`,
354
+ `Browser: ${summary.browser}`,
355
+ `Tags: ${summary.tags || 'all tests'}`,
356
+ `Scenarios: ${summary.summary.total} total, ${summary.summary.passed} passed, ${summary.summary.failed} failed`,
357
+ ''
358
+ ];
359
+ if (summary.failures.length > 0) {
360
+ lines.push('Failures:');
361
+ for (const f of summary.failures) {
362
+ lines.push(` ✗ ${f.feature} › ${f.scenario}`);
363
+ if (f.failedStep) lines.push(` Step: ${f.failedStep}`);
364
+ if (f.error) lines.push(` Error: ${f.error.split('\n')[0]}`);
365
+ }
366
+ } else {
367
+ lines.push('All scenarios passed.');
368
+ }
369
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
370
+ }
371
+ );
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Start
375
+ // ---------------------------------------------------------------------------
376
+
377
+ async function main() {
378
+ const transport = new StdioServerTransport();
379
+ await server.connect(transport);
380
+ }
381
+
382
+ main().catch((err) => {
383
+ console.error(err);
384
+ process.exit(1);
385
+ });
@@ -16,9 +16,27 @@
16
16
  */
17
17
 
18
18
  const { verifyToken } = require('../services/userService');
19
+ const prisma = require('../services/prisma');
19
20
 
20
21
  function jwtAuth(req, res, next) {
21
22
  const auth = req.headers.authorization;
23
+
24
+ // MCP API key — resolves to the first admin user so createdById is always valid
25
+ const mcpKey = process.env.PLUM_MCP_KEY;
26
+ if (mcpKey && auth === `ApiKey ${mcpKey}`) {
27
+ prisma.user
28
+ .findFirst({ where: { role: 'admin' }, select: { id: true } })
29
+ .then((admin) => {
30
+ req.user = { userId: admin?.id ?? null };
31
+ next();
32
+ })
33
+ .catch(() => {
34
+ req.user = { userId: null };
35
+ next();
36
+ });
37
+ return;
38
+ }
39
+
22
40
  if (!auth || !auth.startsWith('Bearer ')) {
23
41
  return res.status(401).json({ error: 'Unauthorized' });
24
42
  }