plum-e2e 2.8.0 → 2.8.2
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/app.js +1 -0
- package/backend/lib/nodeRegister.js +2 -10
- package/backend/mcp/server.js +444 -448
- package/backend/middleware/jwtAuth.js +0 -9
- package/backend/middleware/runnerOrAdmin.js +26 -0
- package/backend/package.json +1 -2
- package/backend/routes/mcp.routes.js +46 -0
- package/backend/routes/runners.routes.js +11 -6
- package/backend/scripts/manage-runners.mjs +7 -6
- package/backend/services/runnerService.js +11 -0
- package/bin/plum.js +0 -27
- package/bin/postinstall.js +5 -5
- package/docker-compose.yml +0 -1
- package/frontend/.svelte-kit/adapter-node/chunks/internal.js +1 -1
- package/frontend/.svelte-kit/adapter-node/manifest-full.js +1 -1
- package/frontend/.svelte-kit/adapter-node/manifest.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/0.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/1.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/11.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/12.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/3.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/5.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/6.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/8.js +1 -1
- package/frontend/.svelte-kit/adapter-node/nodes/9.js +1 -1
- package/frontend/.svelte-kit/generated/server/internal.js +1 -1
- package/frontend/.svelte-kit/output/client/.vite/manifest.json +37 -37
- package/frontend/.svelte-kit/output/client/_app/immutable/chunks/{nURnIknV.js → DEKAA-Fm.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/chunks/{DMyTUV-Y.js → Dhf4xQKo.js} +1 -1
- package/frontend/{build/client/_app/immutable/entry/app.BkBD0hgA.js → .svelte-kit/output/client/_app/immutable/entry/app.B0GH5ONo.js} +2 -2
- package/frontend/{build/client/_app/immutable/entry/start.BxeX1Uxs.js → .svelte-kit/output/client/_app/immutable/entry/start.Bn25CW9X.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{0.ChjDHxSJ.js → 0.-imXt0f5.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{1.DBSWa36f.js → 1.Dom3CKWF.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/11.O4cjXCg4.js → .svelte-kit/output/client/_app/immutable/nodes/11.DMlayLCK.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/12.BWQdeRdX.js → .svelte-kit/output/client/_app/immutable/nodes/12.Bu89A1eX.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{3._S1vrLVF.js → 3.B86rlHXU.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/5.BiFBiWcW.js → .svelte-kit/output/client/_app/immutable/nodes/5.CqTXvadV.js} +1 -1
- package/frontend/{build/client/_app/immutable/nodes/6.BNS-W0R_.js → .svelte-kit/output/client/_app/immutable/nodes/6.BJZ62MCd.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{8.BEUdazrw.js → 8.B4fj2Xyu.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/immutable/nodes/{9.CrLHYPEJ.js → 9.C57_2xq6.js} +1 -1
- package/frontend/.svelte-kit/output/client/_app/version.json +1 -1
- package/frontend/.svelte-kit/output/server/chunks/internal.js +1 -1
- package/frontend/.svelte-kit/output/server/manifest-full.js +1 -1
- package/frontend/.svelte-kit/output/server/manifest.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/0.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/1.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/11.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/12.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/3.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/5.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/6.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/8.js +1 -1
- package/frontend/.svelte-kit/output/server/nodes/9.js +1 -1
- package/frontend/build/client/_app/immutable/chunks/{nURnIknV.js → DEKAA-Fm.js} +1 -1
- package/frontend/build/client/_app/immutable/chunks/DEKAA-Fm.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/DEKAA-Fm.js.gz +0 -0
- package/frontend/build/client/_app/immutable/chunks/{DMyTUV-Y.js → Dhf4xQKo.js} +1 -1
- package/frontend/build/client/_app/immutable/chunks/Dhf4xQKo.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/Dhf4xQKo.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/entry/app.BkBD0hgA.js → build/client/_app/immutable/entry/app.B0GH5ONo.js} +2 -2
- package/frontend/build/client/_app/immutable/entry/app.B0GH5ONo.js.br +0 -0
- package/frontend/build/client/_app/immutable/entry/app.B0GH5ONo.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/entry/start.BxeX1Uxs.js → build/client/_app/immutable/entry/start.Bn25CW9X.js} +1 -1
- package/frontend/build/client/_app/immutable/entry/{start.BxeX1Uxs.js.br → start.Bn25CW9X.js.br} +0 -0
- package/frontend/build/client/_app/immutable/entry/{start.BxeX1Uxs.js.gz → start.Bn25CW9X.js.gz} +0 -0
- package/frontend/build/client/_app/immutable/nodes/{0.ChjDHxSJ.js → 0.-imXt0f5.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/0.-imXt0f5.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/0.-imXt0f5.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/{1.DBSWa36f.js → 1.Dom3CKWF.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/1.Dom3CKWF.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/1.Dom3CKWF.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/11.O4cjXCg4.js → build/client/_app/immutable/nodes/11.DMlayLCK.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/11.DMlayLCK.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/11.DMlayLCK.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/12.BWQdeRdX.js → build/client/_app/immutable/nodes/12.Bu89A1eX.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/12.Bu89A1eX.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/12.Bu89A1eX.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/{3._S1vrLVF.js → 3.B86rlHXU.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/3.B86rlHXU.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/3.B86rlHXU.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/5.BiFBiWcW.js → build/client/_app/immutable/nodes/5.CqTXvadV.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/5.CqTXvadV.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/5.CqTXvadV.js.gz +0 -0
- package/frontend/{.svelte-kit/output/client/_app/immutable/nodes/6.BNS-W0R_.js → build/client/_app/immutable/nodes/6.BJZ62MCd.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/6.BJZ62MCd.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/6.BJZ62MCd.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/{8.BEUdazrw.js → 8.B4fj2Xyu.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/8.B4fj2Xyu.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/8.B4fj2Xyu.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/{9.CrLHYPEJ.js → 9.C57_2xq6.js} +1 -1
- package/frontend/build/client/_app/immutable/nodes/9.C57_2xq6.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/9.C57_2xq6.js.gz +0 -0
- package/frontend/build/client/_app/version.json +1 -1
- package/frontend/build/client/_app/version.json.br +0 -0
- package/frontend/build/client/_app/version.json.gz +0 -0
- package/frontend/build/server/chunks/{0-Cn_2hzqk.js → 0-DG1Uv07a.js} +2 -2
- package/frontend/build/server/chunks/{0-Cn_2hzqk.js.map → 0-DG1Uv07a.js.map} +1 -1
- package/frontend/build/server/chunks/{1-BrRGJFSB.js → 1-iB0ogJU_.js} +2 -2
- package/frontend/build/server/chunks/{1-BrRGJFSB.js.map → 1-iB0ogJU_.js.map} +1 -1
- package/frontend/build/server/chunks/{11-BLPbHJqB.js → 11-Df9vGZ_v.js} +2 -2
- package/frontend/build/server/chunks/{11-BLPbHJqB.js.map → 11-Df9vGZ_v.js.map} +1 -1
- package/frontend/build/server/chunks/{12-S1ujUrHy.js → 12-DqvjCln4.js} +2 -2
- package/frontend/build/server/chunks/{12-S1ujUrHy.js.map → 12-DqvjCln4.js.map} +1 -1
- package/frontend/build/server/chunks/{3-BHkXVoNI.js → 3-BYKCZi4U.js} +2 -2
- package/frontend/build/server/chunks/{3-BHkXVoNI.js.map → 3-BYKCZi4U.js.map} +1 -1
- package/frontend/build/server/chunks/{5-F1ZsXMt2.js → 5-B6Jc1foX.js} +2 -2
- package/frontend/build/server/chunks/{5-F1ZsXMt2.js.map → 5-B6Jc1foX.js.map} +1 -1
- package/frontend/build/server/chunks/{6-FlZLnYJ6.js → 6-dXZKtN86.js} +2 -2
- package/frontend/build/server/chunks/{6-FlZLnYJ6.js.map → 6-dXZKtN86.js.map} +1 -1
- package/frontend/build/server/chunks/{8-DXReUGxi.js → 8-D-Bp7HzS.js} +2 -2
- package/frontend/build/server/chunks/{8-DXReUGxi.js.map → 8-D-Bp7HzS.js.map} +1 -1
- package/frontend/build/server/chunks/{9-Cy_mGmt4.js → 9-BynjuGUO.js} +2 -2
- package/frontend/build/server/chunks/{9-Cy_mGmt4.js.map → 9-BynjuGUO.js.map} +1 -1
- package/frontend/build/server/index.js +1 -1
- package/frontend/build/server/index.js.map +1 -1
- package/frontend/build/server/manifest.js +10 -10
- package/frontend/build/server/manifest.js.map +1 -1
- package/frontend/src/routes/settings/+page.svelte +4 -5
- package/package.json +1 -1
- package/frontend/build/client/_app/immutable/chunks/DMyTUV-Y.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/DMyTUV-Y.js.gz +0 -0
- package/frontend/build/client/_app/immutable/chunks/nURnIknV.js.br +0 -0
- package/frontend/build/client/_app/immutable/chunks/nURnIknV.js.gz +0 -0
- package/frontend/build/client/_app/immutable/entry/app.BkBD0hgA.js.br +0 -0
- package/frontend/build/client/_app/immutable/entry/app.BkBD0hgA.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/0.ChjDHxSJ.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/0.ChjDHxSJ.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/1.DBSWa36f.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/1.DBSWa36f.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/11.O4cjXCg4.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/11.O4cjXCg4.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/12.BWQdeRdX.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/12.BWQdeRdX.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/3._S1vrLVF.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/3._S1vrLVF.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/5.BiFBiWcW.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/5.BiFBiWcW.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/6.BNS-W0R_.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/6.BNS-W0R_.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/8.BEUdazrw.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/8.BEUdazrw.js.gz +0 -0
- package/frontend/build/client/_app/immutable/nodes/9.CrLHYPEJ.js.br +0 -0
- package/frontend/build/client/_app/immutable/nodes/9.CrLHYPEJ.js.gz +0 -0
package/backend/mcp/server.js
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
/**
|
|
7
7
|
* Plum MCP Server
|
|
8
8
|
*
|
|
9
|
-
* Exposes Plum's Test Repository and test runner to
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* PLUM_API_KEY — Must match PLUM_MCP_KEY set in backend/.env
|
|
9
|
+
* Exposes Plum's Test Repository and test runner to any MCP-compatible AI client
|
|
10
|
+
* over Streamable HTTP (see routes/mcp.routes.js for the transport wiring). Tool
|
|
11
|
+
* handlers call the same services the REST routes use directly — this module
|
|
12
|
+
* runs in-process inside the Plum backend, not as a separate subprocess.
|
|
14
13
|
*/
|
|
15
14
|
|
|
15
|
+
const fs = require('fs');
|
|
16
16
|
const path = require('path');
|
|
17
17
|
|
|
18
18
|
// Use absolute paths to bypass the SDK's wildcard export mapping
|
|
@@ -26,38 +26,14 @@ const sdkCjs = path.resolve(
|
|
|
26
26
|
'cjs'
|
|
27
27
|
);
|
|
28
28
|
const { McpServer } = require(path.join(sdkCjs, 'server', 'mcp.js'));
|
|
29
|
-
const { StdioServerTransport } = require(path.join(sdkCjs, 'server', 'stdio.js'));
|
|
30
29
|
const { z } = require('zod');
|
|
31
|
-
const {
|
|
30
|
+
const { TRIGGER_TYPE } = require('../constants/triggers');
|
|
32
31
|
const { JOB_STATUS } = require('../constants/jobStatus');
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// HTTP helpers
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
|
|
41
|
-
async function api(method, path, body) {
|
|
42
|
-
const res = await fetch(`${API_URL}${path}`, {
|
|
43
|
-
method,
|
|
44
|
-
headers: {
|
|
45
|
-
'Content-Type': 'application/json',
|
|
46
|
-
Authorization: `${AUTH_SCHEME.API_KEY} ${API_KEY}`
|
|
47
|
-
},
|
|
48
|
-
body: body !== undefined ? JSON.stringify(body) : undefined
|
|
49
|
-
});
|
|
50
|
-
const json = await res.json().catch(() => ({}));
|
|
51
|
-
if (!res.ok) {
|
|
52
|
-
throw new Error(json.error || `HTTP ${res.status} ${method} ${path}`);
|
|
53
|
-
}
|
|
54
|
-
return json;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const get = (path) => api('GET', path);
|
|
58
|
-
const post = (path, body) => api('POST', path, body);
|
|
59
|
-
const put = (path, body) => api('PUT', path, body);
|
|
60
|
-
const del = (path) => api('DELETE', path);
|
|
32
|
+
const { SCREENSHOTS_DIR } = require('../lib/reportFilename');
|
|
33
|
+
const testSuiteService = require('../services/testSuiteService');
|
|
34
|
+
const testCaseService = require('../services/testCaseService');
|
|
35
|
+
const triggerService = require('../services/triggerService');
|
|
36
|
+
const reportService = require('../services/reportService');
|
|
61
37
|
|
|
62
38
|
// ---------------------------------------------------------------------------
|
|
63
39
|
// Polling helper for test runs
|
|
@@ -67,7 +43,7 @@ async function pollJob(jobId, { maxMs = 600_000, intervalMs = 5_000 } = {}) {
|
|
|
67
43
|
const deadline = Date.now() + maxMs;
|
|
68
44
|
while (Date.now() < deadline) {
|
|
69
45
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
70
|
-
const job =
|
|
46
|
+
const job = triggerService.getJob(jobId);
|
|
71
47
|
if (job.status !== JOB_STATUS.RUNNING) return job;
|
|
72
48
|
}
|
|
73
49
|
return { status: 'timeout', jobId };
|
|
@@ -78,8 +54,6 @@ async function pollJob(jobId, { maxMs = 600_000, intervalMs = 5_000 } = {}) {
|
|
|
78
54
|
// ---------------------------------------------------------------------------
|
|
79
55
|
|
|
80
56
|
function summariseReport(report) {
|
|
81
|
-
// GET /reports/:id hoists content.features to a top-level `features` key
|
|
82
|
-
// and strips `content` entirely — see reportService.getReportDetail.
|
|
83
57
|
const features = report.features ?? report.content?.features ?? [];
|
|
84
58
|
const allScenarios = features.flatMap((f) => f.scenarios ?? []);
|
|
85
59
|
const total = allScenarios.length;
|
|
@@ -114,447 +88,469 @@ function summariseReport(report) {
|
|
|
114
88
|
// ---------------------------------------------------------------------------
|
|
115
89
|
|
|
116
90
|
const SCREENSHOT_FILENAME_RE = /^[\w.-]+\.(png|jpg|jpeg)$/i;
|
|
117
|
-
|
|
118
|
-
function screenshotUrl(filename) {
|
|
119
|
-
return `${API_URL}/screenshots/${filename}`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
91
|
const SCREENSHOT_MIME_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg' };
|
|
123
92
|
|
|
124
93
|
// ---------------------------------------------------------------------------
|
|
125
94
|
// Server setup
|
|
126
95
|
// ---------------------------------------------------------------------------
|
|
127
96
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
page: z.number().int().positive().optional().describe('Page number (default 1)'),
|
|
140
|
-
limit: z.number().int().positive().max(100).optional().describe('Results per page (default 20)')
|
|
141
|
-
},
|
|
142
|
-
async ({ page = 1, limit = 20 }) => {
|
|
143
|
-
const data = await get(`/test-suites?page=${page}&limit=${limit}`);
|
|
144
|
-
return {
|
|
145
|
-
content: [
|
|
146
|
-
{
|
|
147
|
-
type: 'text',
|
|
148
|
-
text: JSON.stringify(data, null, 2)
|
|
149
|
-
}
|
|
150
|
-
]
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
);
|
|
97
|
+
/**
|
|
98
|
+
* Builds a fresh McpServer with all Plum tools registered, closing over the
|
|
99
|
+
* authenticated user for this request (see routes/mcp.routes.js — a new
|
|
100
|
+
* server/transport pair is created per request in stateless mode).
|
|
101
|
+
*/
|
|
102
|
+
function createMcpServer({ userId }) {
|
|
103
|
+
const server = new McpServer({
|
|
104
|
+
name: 'plum',
|
|
105
|
+
version: '1.0.0'
|
|
106
|
+
});
|
|
154
107
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
)
|
|
108
|
+
// -- Test Repository: Suites -----------------------------------------------
|
|
109
|
+
|
|
110
|
+
server.tool(
|
|
111
|
+
'list_test_suites',
|
|
112
|
+
'List all test suites in the Plum Test Repository.',
|
|
113
|
+
{
|
|
114
|
+
page: z.number().int().positive().optional().describe('Page number (default 1)'),
|
|
115
|
+
limit: z
|
|
116
|
+
.number()
|
|
117
|
+
.int()
|
|
118
|
+
.positive()
|
|
119
|
+
.max(100)
|
|
120
|
+
.optional()
|
|
121
|
+
.describe('Results per page (default 20)')
|
|
122
|
+
},
|
|
123
|
+
async ({ page = 1, limit = 20 }) => {
|
|
124
|
+
const data = await testSuiteService.getAll({ page, limit });
|
|
125
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
126
|
+
}
|
|
127
|
+
);
|
|
166
128
|
|
|
167
|
-
server.tool(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
129
|
+
server.tool(
|
|
130
|
+
'get_test_suite',
|
|
131
|
+
'Get a test suite by ID, including all its test cases.',
|
|
132
|
+
{
|
|
133
|
+
suiteId: z.string().describe('The suite ID (e.g. the database UUID, not the displayId)')
|
|
134
|
+
},
|
|
135
|
+
async ({ suiteId }) => {
|
|
136
|
+
const suite = await testSuiteService.getById(suiteId);
|
|
137
|
+
if (!suite) throw new Error('Suite not found');
|
|
138
|
+
return { content: [{ type: 'text', text: JSON.stringify(suite, null, 2) }] };
|
|
139
|
+
}
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
server.tool(
|
|
143
|
+
'create_test_suite',
|
|
144
|
+
'Create a new test suite in the Plum Test Repository.',
|
|
145
|
+
{
|
|
146
|
+
name: z.string().min(1).describe('Suite name, e.g. "User Authentication"'),
|
|
147
|
+
description: z.string().optional().describe('What this suite covers'),
|
|
148
|
+
priority: z
|
|
149
|
+
.enum(['Critical', 'High', 'Medium', 'Low'])
|
|
150
|
+
.optional()
|
|
151
|
+
.describe('Suite priority (default Medium)')
|
|
152
|
+
},
|
|
153
|
+
async ({ name, description, priority }) => {
|
|
154
|
+
const suite = await testSuiteService.create({
|
|
155
|
+
name,
|
|
156
|
+
description,
|
|
157
|
+
priority,
|
|
158
|
+
createdById: userId
|
|
159
|
+
});
|
|
160
|
+
return { content: [{ type: 'text', text: JSON.stringify(suite, null, 2) }] };
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
server.tool(
|
|
165
|
+
'update_test_suite',
|
|
166
|
+
"Update a test suite's name, description, or priority. Only the fields provided are changed.",
|
|
167
|
+
{
|
|
168
|
+
suiteId: z.string().describe('UUID of the suite to update'),
|
|
169
|
+
name: z.string().min(1).optional(),
|
|
170
|
+
description: z.string().optional(),
|
|
171
|
+
priority: z.enum(['Critical', 'High', 'Medium', 'Low']).optional()
|
|
172
|
+
},
|
|
173
|
+
async ({ suiteId, name, description, priority }) => {
|
|
174
|
+
const suite = await testSuiteService.update(suiteId, { name, description, priority });
|
|
175
|
+
return { content: [{ type: 'text', text: JSON.stringify(suite, null, 2) }] };
|
|
176
|
+
}
|
|
177
|
+
);
|
|
183
178
|
|
|
184
|
-
server.tool(
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
179
|
+
server.tool(
|
|
180
|
+
'delete_test_suite',
|
|
181
|
+
'Permanently delete a test suite and all of its test cases. This cannot be undone.',
|
|
182
|
+
{
|
|
183
|
+
suiteId: z.string().describe('UUID of the suite to delete')
|
|
184
|
+
},
|
|
185
|
+
async ({ suiteId }) => {
|
|
186
|
+
await testSuiteService.remove(suiteId);
|
|
187
|
+
return { content: [{ type: 'text', text: `Suite ${suiteId} deleted.` }] };
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// -- Test Repository: Cases -------------------------------------------------
|
|
192
|
+
|
|
193
|
+
server.tool(
|
|
194
|
+
'create_test_case',
|
|
195
|
+
'Create a test case inside an existing test suite.',
|
|
196
|
+
{
|
|
197
|
+
suiteId: z.string().describe('UUID of the parent test suite'),
|
|
198
|
+
title: z
|
|
199
|
+
.string()
|
|
200
|
+
.min(1)
|
|
201
|
+
.describe('Case title, e.g. "User can log in with valid credentials"'),
|
|
202
|
+
description: z.string().optional().describe('Scope, assumptions, or edge cases'),
|
|
203
|
+
priority: z.enum(['Critical', 'High', 'Medium', 'Low']).optional()
|
|
204
|
+
},
|
|
205
|
+
async ({ suiteId, title, description, priority }) => {
|
|
206
|
+
const testCase = await testCaseService.create({
|
|
207
|
+
suiteId,
|
|
208
|
+
title,
|
|
209
|
+
description,
|
|
210
|
+
priority,
|
|
211
|
+
createdById: userId
|
|
212
|
+
});
|
|
213
|
+
return { content: [{ type: 'text', text: JSON.stringify(testCase, null, 2) }] };
|
|
214
|
+
}
|
|
215
|
+
);
|
|
198
216
|
|
|
199
|
-
server.tool(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
217
|
+
server.tool(
|
|
218
|
+
'get_test_case',
|
|
219
|
+
'Get a test case by ID, including its manual steps and recent execution history.',
|
|
220
|
+
{
|
|
221
|
+
caseId: z.string().describe('UUID of the test case')
|
|
222
|
+
},
|
|
223
|
+
async ({ caseId }) => {
|
|
224
|
+
const testCase = await testCaseService.getById(caseId);
|
|
225
|
+
if (!testCase) throw new Error('Test case not found');
|
|
226
|
+
return { content: [{ type: 'text', text: JSON.stringify(testCase, null, 2) }] };
|
|
227
|
+
}
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
server.tool(
|
|
231
|
+
'update_test_case',
|
|
232
|
+
"Update a test case's title, description, or priority. Only the fields provided are changed.",
|
|
233
|
+
{
|
|
234
|
+
caseId: z.string().describe('UUID of the test case to update'),
|
|
235
|
+
title: z.string().min(1).optional(),
|
|
236
|
+
description: z.string().optional(),
|
|
237
|
+
priority: z.enum(['Critical', 'High', 'Medium', 'Low']).optional()
|
|
238
|
+
},
|
|
239
|
+
async ({ caseId, title, description, priority }) => {
|
|
240
|
+
const testCase = await testCaseService.update(caseId, { title, description, priority });
|
|
241
|
+
return { content: [{ type: 'text', text: JSON.stringify(testCase, null, 2) }] };
|
|
242
|
+
}
|
|
243
|
+
);
|
|
210
244
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
245
|
+
server.tool(
|
|
246
|
+
'delete_test_case',
|
|
247
|
+
'Permanently delete a test case and its steps. This cannot be undone.',
|
|
248
|
+
{
|
|
249
|
+
caseId: z.string().describe('UUID of the test case to delete')
|
|
250
|
+
},
|
|
251
|
+
async ({ caseId }) => {
|
|
252
|
+
await testCaseService.remove(caseId);
|
|
253
|
+
return { content: [{ type: 'text', text: `Test case ${caseId} deleted.` }] };
|
|
254
|
+
}
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
server.tool(
|
|
258
|
+
'set_test_steps',
|
|
259
|
+
'Set (replace) the manual test steps for a test case. Each step has an action, optional test data, and optional expected output.',
|
|
260
|
+
{
|
|
261
|
+
caseId: z.string().describe('UUID of the test case'),
|
|
262
|
+
steps: z
|
|
263
|
+
.array(
|
|
264
|
+
z.object({
|
|
265
|
+
action: z.string().describe('What the tester does'),
|
|
266
|
+
testData: z.string().optional().describe('Specific input or data to use'),
|
|
267
|
+
expectedOutput: z.string().optional().describe('What should happen')
|
|
268
|
+
})
|
|
269
|
+
)
|
|
270
|
+
.describe('Ordered list of steps. Replaces any existing steps.')
|
|
271
|
+
},
|
|
272
|
+
async ({ caseId, steps }) => {
|
|
273
|
+
const saved = await testCaseService.upsertSteps(caseId, steps);
|
|
274
|
+
return { content: [{ type: 'text', text: JSON.stringify(saved, null, 2) }] };
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
// -- Test Runner ------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
server.tool(
|
|
281
|
+
'run_tests',
|
|
282
|
+
[
|
|
283
|
+
'Trigger a Plum test run and wait for results. Returns a pass/fail summary.',
|
|
284
|
+
'',
|
|
285
|
+
'For PR testing: pass baseUrl to override the default BASE_URL for this run only.',
|
|
286
|
+
'If the app runs on localhost, use host.docker.internal instead (e.g. http://host.docker.internal:3000)',
|
|
287
|
+
'so the Plum Docker container can reach it.',
|
|
288
|
+
'',
|
|
289
|
+
'Tag filter examples: "@login" "@TC-001 or @TC-002" "@smoke and not @slow"',
|
|
290
|
+
'Leave tag blank to run all tests.'
|
|
291
|
+
].join('\n'),
|
|
292
|
+
{
|
|
293
|
+
tag: z
|
|
294
|
+
.string()
|
|
295
|
+
.optional()
|
|
296
|
+
.describe('Cucumber tag expression to filter scenarios. Leave blank to run all.'),
|
|
297
|
+
browser: z
|
|
298
|
+
.enum(['chromium', 'firefox'])
|
|
299
|
+
.optional()
|
|
300
|
+
.describe('Browser to run tests in (default chromium)'),
|
|
301
|
+
workers: z
|
|
302
|
+
.number()
|
|
303
|
+
.int()
|
|
304
|
+
.positive()
|
|
305
|
+
.max(10)
|
|
306
|
+
.optional()
|
|
307
|
+
.describe('Number of parallel workers (default 1)'),
|
|
308
|
+
baseUrl: z
|
|
309
|
+
.string()
|
|
310
|
+
.url()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe(
|
|
313
|
+
'Override the app URL for this run only. Use host.docker.internal instead of localhost.'
|
|
314
|
+
),
|
|
315
|
+
testRunId: z
|
|
316
|
+
.string()
|
|
317
|
+
.optional()
|
|
318
|
+
.describe(
|
|
319
|
+
'Link results to a Plum Test Run. Automated entries will be marked pass/fail automatically.'
|
|
320
|
+
)
|
|
321
|
+
},
|
|
322
|
+
async ({ tag, browser, workers, baseUrl, testRunId }) => {
|
|
323
|
+
const jobId = await triggerService.startRun({
|
|
324
|
+
tag,
|
|
325
|
+
browser,
|
|
326
|
+
workers,
|
|
327
|
+
baseUrl,
|
|
328
|
+
testRunId,
|
|
329
|
+
trigger: TRIGGER_TYPE.MCP
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const job = await pollJob(jobId);
|
|
333
|
+
|
|
334
|
+
if (job.status === 'timeout') {
|
|
335
|
+
return {
|
|
336
|
+
content: [
|
|
337
|
+
{
|
|
338
|
+
type: 'text',
|
|
339
|
+
text: `Tests are still running after 10 minutes. Job ID: ${jobId}\nUse get_run_status("${jobId}") to check later.`
|
|
340
|
+
}
|
|
341
|
+
]
|
|
342
|
+
};
|
|
343
|
+
}
|
|
227
344
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
);
|
|
345
|
+
if (!job.reportId) {
|
|
346
|
+
return {
|
|
347
|
+
content: [
|
|
348
|
+
{
|
|
349
|
+
type: 'text',
|
|
350
|
+
text: `Tests finished with exit code ${job.exitCode} but no report was found. The run may have been cancelled or produced no output.`
|
|
351
|
+
}
|
|
352
|
+
]
|
|
353
|
+
};
|
|
354
|
+
}
|
|
239
355
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
)
|
|
356
|
+
const report = await reportService.getReportDetail(job.reportId);
|
|
357
|
+
const summary = summariseReport(report);
|
|
358
|
+
|
|
359
|
+
const lines = [
|
|
360
|
+
`Status: ${summary.status?.toUpperCase()}`,
|
|
361
|
+
`Scenarios: ${summary.summary.total} total, ${summary.summary.passed} passed, ${summary.summary.failed} failed`,
|
|
362
|
+
`Browser: ${summary.browser}`,
|
|
363
|
+
`Tags: ${summary.tags || 'all tests'}`,
|
|
364
|
+
''
|
|
365
|
+
];
|
|
366
|
+
|
|
367
|
+
if (summary.failures.length > 0) {
|
|
368
|
+
lines.push('Failed scenarios:');
|
|
369
|
+
for (const f of summary.failures) {
|
|
370
|
+
lines.push(` ✗ ${f.feature} › ${f.scenario}`);
|
|
371
|
+
if (f.failedStep) lines.push(` Step: ${f.failedStep}`);
|
|
372
|
+
if (f.error) lines.push(` Error: ${f.error.split('\n')[0]}`);
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
lines.push('All scenarios passed.');
|
|
376
|
+
}
|
|
254
377
|
|
|
255
|
-
|
|
256
|
-
'delete_test_case',
|
|
257
|
-
'Permanently delete a test case and its steps. This cannot be undone.',
|
|
258
|
-
{
|
|
259
|
-
caseId: z.string().describe('UUID of the test case to delete')
|
|
260
|
-
},
|
|
261
|
-
async ({ caseId }) => {
|
|
262
|
-
await del(`/test-cases/${caseId}`);
|
|
263
|
-
return { content: [{ type: 'text', text: `Test case ${caseId} deleted.` }] };
|
|
264
|
-
}
|
|
265
|
-
);
|
|
378
|
+
lines.push('', `Report ID: ${summary.id}`);
|
|
266
379
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
{
|
|
271
|
-
caseId: z.string().describe('UUID of the test case'),
|
|
272
|
-
steps: z
|
|
273
|
-
.array(
|
|
274
|
-
z.object({
|
|
275
|
-
action: z.string().describe('What the tester does'),
|
|
276
|
-
testData: z.string().optional().describe('Specific input or data to use'),
|
|
277
|
-
expectedOutput: z.string().optional().describe('What should happen')
|
|
278
|
-
})
|
|
279
|
-
)
|
|
280
|
-
.describe('Ordered list of steps. Replaces any existing steps.')
|
|
281
|
-
},
|
|
282
|
-
async ({ caseId, steps }) => {
|
|
283
|
-
const data = await put(`/test-cases/${caseId}/steps`, { steps });
|
|
284
|
-
return { content: [{ type: 'text', text: JSON.stringify(data.steps, null, 2) }] };
|
|
285
|
-
}
|
|
286
|
-
);
|
|
380
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
381
|
+
}
|
|
382
|
+
);
|
|
287
383
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
.url()
|
|
321
|
-
.optional()
|
|
322
|
-
.describe(
|
|
323
|
-
'Override the app URL for this run only. Use host.docker.internal instead of localhost.'
|
|
324
|
-
),
|
|
325
|
-
testRunId: z
|
|
326
|
-
.string()
|
|
327
|
-
.optional()
|
|
328
|
-
.describe(
|
|
329
|
-
'Link results to a Plum Test Run. Automated entries will be marked pass/fail automatically.'
|
|
330
|
-
)
|
|
331
|
-
},
|
|
332
|
-
async ({ tag, browser, workers, baseUrl, testRunId }) => {
|
|
333
|
-
const { jobId } = await post('/trigger', {
|
|
334
|
-
tag,
|
|
335
|
-
browser,
|
|
336
|
-
workers,
|
|
337
|
-
baseUrl,
|
|
338
|
-
testRunId,
|
|
339
|
-
source: 'mcp'
|
|
340
|
-
});
|
|
384
|
+
server.tool(
|
|
385
|
+
'get_run_status',
|
|
386
|
+
'Check the status of a test run that was started with run_tests. Returns status, exit code, and report ID when done.',
|
|
387
|
+
{
|
|
388
|
+
jobId: z.string().uuid().describe('Job ID returned by run_tests')
|
|
389
|
+
},
|
|
390
|
+
async ({ jobId }) => {
|
|
391
|
+
const job = triggerService.getJob(jobId);
|
|
392
|
+
if (!job) throw new Error('Job not found');
|
|
393
|
+
return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }] };
|
|
394
|
+
}
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
// -- Reports ----------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
server.tool(
|
|
400
|
+
'list_reports',
|
|
401
|
+
'List recent Plum test reports.',
|
|
402
|
+
{
|
|
403
|
+
limit: z
|
|
404
|
+
.number()
|
|
405
|
+
.int()
|
|
406
|
+
.positive()
|
|
407
|
+
.max(50)
|
|
408
|
+
.optional()
|
|
409
|
+
.describe('Number of reports (default 10)')
|
|
410
|
+
},
|
|
411
|
+
async ({ limit = 10 }) => {
|
|
412
|
+
const data = await reportService.getReports({ limit });
|
|
413
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
414
|
+
}
|
|
415
|
+
);
|
|
341
416
|
|
|
342
|
-
|
|
417
|
+
server.tool(
|
|
418
|
+
'get_report_summary',
|
|
419
|
+
'Get a human-readable summary of a Plum test report, including a list of failed scenarios.',
|
|
420
|
+
{
|
|
421
|
+
reportId: z.number().int().describe('Numeric report ID')
|
|
422
|
+
},
|
|
423
|
+
async ({ reportId }) => {
|
|
424
|
+
const detail = await reportService.getReportDetail(reportId);
|
|
425
|
+
if (!detail) throw new Error('Report not found');
|
|
426
|
+
const summary = summariseReport(detail);
|
|
427
|
+
const lines = [
|
|
428
|
+
`Report #${summary.id} — ${summary.status?.toUpperCase()}`,
|
|
429
|
+
`Date: ${summary.createdAt}`,
|
|
430
|
+
`Browser: ${summary.browser}`,
|
|
431
|
+
`Tags: ${summary.tags || 'all tests'}`,
|
|
432
|
+
`Scenarios: ${summary.summary.total} total, ${summary.summary.passed} passed, ${summary.summary.failed} failed`,
|
|
433
|
+
''
|
|
434
|
+
];
|
|
435
|
+
if (summary.failures.length > 0) {
|
|
436
|
+
lines.push('Failures:');
|
|
437
|
+
for (const f of summary.failures) {
|
|
438
|
+
lines.push(` ✗ ${f.feature} › ${f.scenario}`);
|
|
439
|
+
if (f.failedStep) lines.push(` Step: ${f.failedStep}`);
|
|
440
|
+
if (f.error) lines.push(` Error: ${f.error.split('\n')[0]}`);
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
lines.push('All scenarios passed.');
|
|
444
|
+
}
|
|
445
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
446
|
+
}
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
server.tool(
|
|
450
|
+
'get_report_scenario_detail',
|
|
451
|
+
[
|
|
452
|
+
'Get full per-scenario, per-step detail for a Plum test report — the data needed to diagnose',
|
|
453
|
+
"and self-heal a failing test: every step's status, duration, full error message, and",
|
|
454
|
+
'screenshot URL (if one was captured for that step).',
|
|
455
|
+
'',
|
|
456
|
+
'Use get_report_screenshot to fetch the actual screenshot image for a filename returned here,',
|
|
457
|
+
'and get_report_logs for the raw test-run stdout/stderr.'
|
|
458
|
+
].join('\n'),
|
|
459
|
+
{
|
|
460
|
+
reportId: z.number().int().describe('Numeric report ID'),
|
|
461
|
+
onlyFailed: z
|
|
462
|
+
.boolean()
|
|
463
|
+
.optional()
|
|
464
|
+
.describe('Only include scenarios with a failed step (default true)')
|
|
465
|
+
},
|
|
466
|
+
async ({ reportId, onlyFailed = true }) => {
|
|
467
|
+
const detail = await reportService.getReportDetail(reportId);
|
|
468
|
+
if (!detail) throw new Error('Report not found');
|
|
469
|
+
const features = detail.features ?? [];
|
|
470
|
+
|
|
471
|
+
const scenarios = features.flatMap((feature) =>
|
|
472
|
+
(feature.scenarios ?? [])
|
|
473
|
+
.filter((s) => !onlyFailed || s.status === 'failed')
|
|
474
|
+
.map((s) => ({
|
|
475
|
+
feature: feature.name,
|
|
476
|
+
scenario: s.name,
|
|
477
|
+
tags: s.tags ?? [],
|
|
478
|
+
status: s.status,
|
|
479
|
+
duration: s.duration,
|
|
480
|
+
steps: (s.steps ?? []).map((st) => ({
|
|
481
|
+
keyword: st.keyword,
|
|
482
|
+
name: st.name,
|
|
483
|
+
status: st.status,
|
|
484
|
+
duration: st.duration,
|
|
485
|
+
error: st.error ?? null,
|
|
486
|
+
screenshot: st.screenshot ?? null
|
|
487
|
+
}))
|
|
488
|
+
}))
|
|
489
|
+
);
|
|
343
490
|
|
|
344
|
-
if (job.status === 'timeout') {
|
|
345
491
|
return {
|
|
346
|
-
content: [
|
|
347
|
-
{
|
|
348
|
-
type: 'text',
|
|
349
|
-
text: `Tests are still running after 10 minutes. Job ID: ${jobId}\nUse get_run_status("${jobId}") to check later.`
|
|
350
|
-
}
|
|
351
|
-
]
|
|
492
|
+
content: [{ type: 'text', text: JSON.stringify({ reportId, scenarios }, null, 2) }]
|
|
352
493
|
};
|
|
353
494
|
}
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
server.tool(
|
|
498
|
+
'get_report_screenshot',
|
|
499
|
+
'Fetch a screenshot captured during a test step and return it as an image, so it can be viewed ' +
|
|
500
|
+
"directly. Get the filename from get_report_scenario_detail's step.screenshot field.",
|
|
501
|
+
{
|
|
502
|
+
filename: z.string().describe('Screenshot filename, e.g. "3f9c1e2a-....png"')
|
|
503
|
+
},
|
|
504
|
+
async ({ filename }) => {
|
|
505
|
+
if (!SCREENSHOT_FILENAME_RE.test(filename)) {
|
|
506
|
+
throw new Error(`Invalid screenshot filename: ${filename}`);
|
|
507
|
+
}
|
|
508
|
+
const filePath = path.join(SCREENSHOTS_DIR, filename);
|
|
509
|
+
let buffer;
|
|
510
|
+
try {
|
|
511
|
+
buffer = await fs.promises.readFile(filePath);
|
|
512
|
+
} catch {
|
|
513
|
+
throw new Error(`Screenshot not found: ${filename}`);
|
|
514
|
+
}
|
|
515
|
+
const ext = filename.split('.').pop().toLowerCase();
|
|
354
516
|
|
|
355
|
-
if (!job.reportId) {
|
|
356
517
|
return {
|
|
357
518
|
content: [
|
|
358
519
|
{
|
|
359
|
-
type: '
|
|
360
|
-
|
|
520
|
+
type: 'image',
|
|
521
|
+
data: buffer.toString('base64'),
|
|
522
|
+
mimeType: SCREENSHOT_MIME_TYPES[ext]
|
|
361
523
|
}
|
|
362
524
|
]
|
|
363
525
|
};
|
|
364
526
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
lines.push('', `Report ID: ${summary.id}`);
|
|
389
|
-
|
|
390
|
-
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
391
|
-
}
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
server.tool(
|
|
395
|
-
'get_run_status',
|
|
396
|
-
'Check the status of a test run that was started with run_tests. Returns status, exit code, and report ID when done.',
|
|
397
|
-
{
|
|
398
|
-
jobId: z.string().uuid().describe('Job ID returned by run_tests')
|
|
399
|
-
},
|
|
400
|
-
async ({ jobId }) => {
|
|
401
|
-
const job = await get(`/trigger/${jobId}`);
|
|
402
|
-
return { content: [{ type: 'text', text: JSON.stringify(job, null, 2) }] };
|
|
403
|
-
}
|
|
404
|
-
);
|
|
405
|
-
|
|
406
|
-
// -- Reports ----------------------------------------------------------------
|
|
407
|
-
|
|
408
|
-
server.tool(
|
|
409
|
-
'list_reports',
|
|
410
|
-
'List recent Plum test reports.',
|
|
411
|
-
{
|
|
412
|
-
limit: z.number().int().positive().max(50).optional().describe('Number of reports (default 10)')
|
|
413
|
-
},
|
|
414
|
-
async ({ limit = 10 }) => {
|
|
415
|
-
const data = await get(`/reports?limit=${limit}`);
|
|
416
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
417
|
-
}
|
|
418
|
-
);
|
|
419
|
-
|
|
420
|
-
server.tool(
|
|
421
|
-
'get_report_summary',
|
|
422
|
-
'Get a human-readable summary of a Plum test report, including a list of failed scenarios.',
|
|
423
|
-
{
|
|
424
|
-
reportId: z.number().int().describe('Numeric report ID')
|
|
425
|
-
},
|
|
426
|
-
async ({ reportId }) => {
|
|
427
|
-
const data = await get(`/reports/${reportId}`);
|
|
428
|
-
const summary = summariseReport(data.report ?? data);
|
|
429
|
-
const lines = [
|
|
430
|
-
`Report #${summary.id} — ${summary.status?.toUpperCase()}`,
|
|
431
|
-
`Date: ${summary.createdAt}`,
|
|
432
|
-
`Browser: ${summary.browser}`,
|
|
433
|
-
`Tags: ${summary.tags || 'all tests'}`,
|
|
434
|
-
`Scenarios: ${summary.summary.total} total, ${summary.summary.passed} passed, ${summary.summary.failed} failed`,
|
|
435
|
-
''
|
|
436
|
-
];
|
|
437
|
-
if (summary.failures.length > 0) {
|
|
438
|
-
lines.push('Failures:');
|
|
439
|
-
for (const f of summary.failures) {
|
|
440
|
-
lines.push(` ✗ ${f.feature} › ${f.scenario}`);
|
|
441
|
-
if (f.failedStep) lines.push(` Step: ${f.failedStep}`);
|
|
442
|
-
if (f.error) lines.push(` Error: ${f.error.split('\n')[0]}`);
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
server.tool(
|
|
530
|
+
'get_report_logs',
|
|
531
|
+
'Get the raw stdout/stderr log output captured during a Plum test run, tagged per runner. ' +
|
|
532
|
+
'Useful for diagnosing failures with no clear step-level error (crashes, timeouts, setup errors).',
|
|
533
|
+
{
|
|
534
|
+
reportId: z.number().int().describe('Numeric report ID'),
|
|
535
|
+
tail: z
|
|
536
|
+
.number()
|
|
537
|
+
.int()
|
|
538
|
+
.positive()
|
|
539
|
+
.optional()
|
|
540
|
+
.describe('Only return the last N lines (default: full log)')
|
|
541
|
+
},
|
|
542
|
+
async ({ reportId, tail }) => {
|
|
543
|
+
const detail = await reportService.getReportDetail(reportId);
|
|
544
|
+
if (!detail) throw new Error('Report not found');
|
|
545
|
+
let logs = detail.logs ?? '';
|
|
546
|
+
if (tail) {
|
|
547
|
+
logs = logs.split('\n').slice(-tail).join('\n');
|
|
443
548
|
}
|
|
444
|
-
|
|
445
|
-
lines.push('All scenarios passed.');
|
|
549
|
+
return { content: [{ type: 'text', text: logs || '(no logs captured for this report)' }] };
|
|
446
550
|
}
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
);
|
|
450
|
-
|
|
451
|
-
server.tool(
|
|
452
|
-
'get_report_scenario_detail',
|
|
453
|
-
[
|
|
454
|
-
'Get full per-scenario, per-step detail for a Plum test report — the data needed to diagnose',
|
|
455
|
-
"and self-heal a failing test: every step's status, duration, full error message, and",
|
|
456
|
-
'screenshot URL (if one was captured for that step).',
|
|
457
|
-
'',
|
|
458
|
-
'Use get_report_screenshot to fetch the actual screenshot image for a filename returned here,',
|
|
459
|
-
'and get_report_logs for the raw test-run stdout/stderr.'
|
|
460
|
-
].join('\n'),
|
|
461
|
-
{
|
|
462
|
-
reportId: z.number().int().describe('Numeric report ID'),
|
|
463
|
-
onlyFailed: z
|
|
464
|
-
.boolean()
|
|
465
|
-
.optional()
|
|
466
|
-
.describe('Only include scenarios with a failed step (default true)')
|
|
467
|
-
},
|
|
468
|
-
async ({ reportId, onlyFailed = true }) => {
|
|
469
|
-
const data = await get(`/reports/${reportId}`);
|
|
470
|
-
const features = data.features ?? data.content?.features ?? [];
|
|
471
|
-
|
|
472
|
-
const scenarios = features.flatMap((feature) =>
|
|
473
|
-
(feature.scenarios ?? [])
|
|
474
|
-
.filter((s) => !onlyFailed || s.status === 'failed')
|
|
475
|
-
.map((s) => ({
|
|
476
|
-
feature: feature.name,
|
|
477
|
-
scenario: s.name,
|
|
478
|
-
tags: s.tags ?? [],
|
|
479
|
-
status: s.status,
|
|
480
|
-
duration: s.duration,
|
|
481
|
-
steps: (s.steps ?? []).map((st) => ({
|
|
482
|
-
keyword: st.keyword,
|
|
483
|
-
name: st.name,
|
|
484
|
-
status: st.status,
|
|
485
|
-
duration: st.duration,
|
|
486
|
-
error: st.error ?? null,
|
|
487
|
-
screenshot: st.screenshot ?? null,
|
|
488
|
-
screenshotUrl: st.screenshot ? screenshotUrl(st.screenshot) : null
|
|
489
|
-
}))
|
|
490
|
-
}))
|
|
491
|
-
);
|
|
492
|
-
|
|
493
|
-
return { content: [{ type: 'text', text: JSON.stringify({ reportId, scenarios }, null, 2) }] };
|
|
494
|
-
}
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
server.tool(
|
|
498
|
-
'get_report_screenshot',
|
|
499
|
-
'Fetch a screenshot captured during a test step and return it as an image, so it can be viewed ' +
|
|
500
|
-
"directly. Get the filename from get_report_scenario_detail's step.screenshot field.",
|
|
501
|
-
{
|
|
502
|
-
filename: z.string().describe('Screenshot filename, e.g. "3f9c1e2a-....png"')
|
|
503
|
-
},
|
|
504
|
-
async ({ filename }) => {
|
|
505
|
-
if (!SCREENSHOT_FILENAME_RE.test(filename)) {
|
|
506
|
-
throw new Error(`Invalid screenshot filename: ${filename}`);
|
|
507
|
-
}
|
|
508
|
-
const res = await fetch(screenshotUrl(filename));
|
|
509
|
-
if (!res.ok) throw new Error(`Screenshot not found: ${filename}`);
|
|
510
|
-
const buffer = Buffer.from(await res.arrayBuffer());
|
|
511
|
-
const ext = filename.split('.').pop().toLowerCase();
|
|
512
|
-
|
|
513
|
-
return {
|
|
514
|
-
content: [
|
|
515
|
-
{
|
|
516
|
-
type: 'image',
|
|
517
|
-
data: buffer.toString('base64'),
|
|
518
|
-
mimeType: SCREENSHOT_MIME_TYPES[ext]
|
|
519
|
-
}
|
|
520
|
-
]
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
);
|
|
524
|
-
|
|
525
|
-
server.tool(
|
|
526
|
-
'get_report_logs',
|
|
527
|
-
'Get the raw stdout/stderr log output captured during a Plum test run, tagged per runner. ' +
|
|
528
|
-
'Useful for diagnosing failures with no clear step-level error (crashes, timeouts, setup errors).',
|
|
529
|
-
{
|
|
530
|
-
reportId: z.number().int().describe('Numeric report ID'),
|
|
531
|
-
tail: z
|
|
532
|
-
.number()
|
|
533
|
-
.int()
|
|
534
|
-
.positive()
|
|
535
|
-
.optional()
|
|
536
|
-
.describe('Only return the last N lines (default: full log)')
|
|
537
|
-
},
|
|
538
|
-
async ({ reportId, tail }) => {
|
|
539
|
-
const data = await get(`/reports/${reportId}`);
|
|
540
|
-
let logs = data.logs ?? '';
|
|
541
|
-
if (tail) {
|
|
542
|
-
logs = logs.split('\n').slice(-tail).join('\n');
|
|
543
|
-
}
|
|
544
|
-
return { content: [{ type: 'text', text: logs || '(no logs captured for this report)' }] };
|
|
545
|
-
}
|
|
546
|
-
);
|
|
547
|
-
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
// Start
|
|
550
|
-
// ---------------------------------------------------------------------------
|
|
551
|
+
);
|
|
551
552
|
|
|
552
|
-
|
|
553
|
-
const transport = new StdioServerTransport();
|
|
554
|
-
await server.connect(transport);
|
|
553
|
+
return server;
|
|
555
554
|
}
|
|
556
555
|
|
|
557
|
-
|
|
558
|
-
console.error(err);
|
|
559
|
-
process.exit(1);
|
|
560
|
-
});
|
|
556
|
+
module.exports = { createMcpServer };
|