@testkase/mcp-server 2.1.0 → 2.3.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 +123 -154
- package/build/index.js +688 -175
- package/build/index.js.map +1 -1
- package/build/test-config.js +1 -0
- package/build/test-config.js.map +1 -1
- package/package.json +40 -33
- package/PAT_APIS_INVENTORY.csv +0 -27
package/build/index.js
CHANGED
|
@@ -4,9 +4,31 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import axios from "axios";
|
|
6
6
|
const config = {
|
|
7
|
-
apiBaseUrl: process.env.TESTKASE_API_BASE_URL || "https://
|
|
7
|
+
apiBaseUrl: (process.env.TESTKASE_API_BASE_URL || "https://api.testkase.com").replace(/\/+$/, ""),
|
|
8
8
|
patToken: process.env.TESTKASE_PAT_TOKEN,
|
|
9
|
+
// Only needed when the PAT user belongs to more than one organization
|
|
10
|
+
organizationId: process.env.TESTKASE_ORGANIZATION_ID
|
|
11
|
+
? Number(process.env.TESTKASE_ORGANIZATION_ID)
|
|
12
|
+
: undefined,
|
|
13
|
+
appUrl: process.env.TESTKASE_APP_URL?.replace(/\/+$/, ""),
|
|
9
14
|
};
|
|
15
|
+
const VERSION = "2.3.0";
|
|
16
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
17
|
+
// ─── Frontend URL helper ─────────────────────────────────────────────────────
|
|
18
|
+
// Test management lives on its own subdomain
|
|
19
|
+
const KNOWN_APP_HOSTS = {
|
|
20
|
+
"api.testkase.com": "https://test-management.testkase.com",
|
|
21
|
+
"apiqa.testkase.com": "https://test-management-qa.testkase.com",
|
|
22
|
+
};
|
|
23
|
+
function getFrontendBaseUrl() {
|
|
24
|
+
if (config.appUrl)
|
|
25
|
+
return config.appUrl;
|
|
26
|
+
const url = new URL(config.apiBaseUrl);
|
|
27
|
+
if (KNOWN_APP_HOSTS[url.hostname])
|
|
28
|
+
return KNOWN_APP_HOSTS[url.hostname];
|
|
29
|
+
url.hostname = url.hostname.replace(/^api\.?/, "");
|
|
30
|
+
return url.origin;
|
|
31
|
+
}
|
|
10
32
|
// ─── Auth helpers (reused from v1) ───────────────────────────────────────────
|
|
11
33
|
function validateAuth() {
|
|
12
34
|
if (!config.patToken) {
|
|
@@ -27,11 +49,13 @@ function getApiHeaders() {
|
|
|
27
49
|
// ─── Cached org ID (auto-resolved from PAT, never a tool param) ─────────────
|
|
28
50
|
let cachedOrganizationId = null;
|
|
29
51
|
async function getOrganizationId() {
|
|
52
|
+
if (config.organizationId && !isNaN(config.organizationId))
|
|
53
|
+
return config.organizationId;
|
|
30
54
|
if (cachedOrganizationId !== null)
|
|
31
55
|
return cachedOrganizationId;
|
|
32
56
|
validateAuth();
|
|
33
57
|
try {
|
|
34
|
-
const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders() });
|
|
58
|
+
const response = await axios.get(`${config.apiBaseUrl}/api/v1/organizations/my-organization`, { headers: getApiHeaders(), timeout: REQUEST_TIMEOUT_MS });
|
|
35
59
|
const orgData = response.data?.data || response.data;
|
|
36
60
|
if (orgData?.organization_id) {
|
|
37
61
|
cachedOrganizationId = orgData.organization_id;
|
|
@@ -42,7 +66,10 @@ async function getOrganizationId() {
|
|
|
42
66
|
catch (error) {
|
|
43
67
|
if (error instanceof Error && error.message.includes("Could not determine"))
|
|
44
68
|
throw error;
|
|
45
|
-
|
|
69
|
+
const status = axios.isAxiosError(error) ? error.response?.status : undefined;
|
|
70
|
+
throw new Error(status === 401 || status === 403
|
|
71
|
+
? `Failed to fetch organization (status ${status}). Your PAT token is invalid, expired or revoked.`
|
|
72
|
+
: `Failed to fetch organization${status ? ` (status ${status})` : ""}. Check TESTKASE_API_BASE_URL and your network.`);
|
|
46
73
|
}
|
|
47
74
|
}
|
|
48
75
|
// ─── API call helper ─────────────────────────────────────────────────────────
|
|
@@ -65,9 +92,16 @@ async function callApi(method, path, options = {}) {
|
|
|
65
92
|
method,
|
|
66
93
|
url,
|
|
67
94
|
headers: getApiHeaders(),
|
|
95
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
68
96
|
...(options.body !== undefined && { data: options.body }),
|
|
69
97
|
});
|
|
70
|
-
|
|
98
|
+
// Some endpoints report "not found" as HTTP 200 with status "error"
|
|
99
|
+
const data = response.data;
|
|
100
|
+
if (data && typeof data === "object" && data.status === "error") {
|
|
101
|
+
const detail = data.message || data.success || data.error || "Request failed";
|
|
102
|
+
throw new Error(`API returned an error: ${String(detail).slice(0, 500)}`);
|
|
103
|
+
}
|
|
104
|
+
return data;
|
|
71
105
|
}
|
|
72
106
|
// ─── Error handler (reused) ──────────────────────────────────────────────────
|
|
73
107
|
function handleApiError(error) {
|
|
@@ -76,7 +110,15 @@ function handleApiError(error) {
|
|
|
76
110
|
let msg = `API request failed: ${e.message}`;
|
|
77
111
|
if (e.response) {
|
|
78
112
|
msg += `\nStatus: ${e.response.status}`;
|
|
79
|
-
|
|
113
|
+
if (e.response.status === 401) {
|
|
114
|
+
cachedOrganizationId = null;
|
|
115
|
+
}
|
|
116
|
+
if (e.response.data) {
|
|
117
|
+
const d = e.response.data;
|
|
118
|
+
const safeMsg = d.message || d.error || (typeof d === "string" ? d : undefined);
|
|
119
|
+
if (safeMsg)
|
|
120
|
+
msg += `\nDetails: ${String(safeMsg).slice(0, 500)}`;
|
|
121
|
+
}
|
|
80
122
|
}
|
|
81
123
|
return new Error(msg);
|
|
82
124
|
}
|
|
@@ -88,6 +130,199 @@ function jsonResponse(data) {
|
|
|
88
130
|
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
89
131
|
};
|
|
90
132
|
}
|
|
133
|
+
function safeJsonParse(raw, fieldName) {
|
|
134
|
+
if (typeof raw !== "string")
|
|
135
|
+
return raw;
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(raw);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
throw new Error(`Invalid JSON in ${fieldName}. Ensure the value is valid JSON.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function validateId(id, fieldName) {
|
|
144
|
+
const str = String(id);
|
|
145
|
+
if (!/^[a-zA-Z0-9_\-]+$/.test(str)) {
|
|
146
|
+
throw new Error(`Invalid ${fieldName}: contains disallowed characters`);
|
|
147
|
+
}
|
|
148
|
+
return str;
|
|
149
|
+
}
|
|
150
|
+
function toIdList(raw) {
|
|
151
|
+
return Array.isArray(raw) ? raw.join(",") : String(raw);
|
|
152
|
+
}
|
|
153
|
+
function toStringArray(raw) {
|
|
154
|
+
if (Array.isArray(raw))
|
|
155
|
+
return raw.map((v) => String(v).trim()).filter(Boolean);
|
|
156
|
+
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
157
|
+
}
|
|
158
|
+
// Normalise one test step; the backend requires `description` to be a string
|
|
159
|
+
function normalizeStep(s) {
|
|
160
|
+
if (typeof s === "string")
|
|
161
|
+
return { description: s };
|
|
162
|
+
const step = {
|
|
163
|
+
description: s?.description ?? s?.step ?? s?.action ?? "",
|
|
164
|
+
test_data: s?.test_data ?? s?.testData,
|
|
165
|
+
expected_result: s?.expected_result ?? s?.expectedResult ?? s?.expected,
|
|
166
|
+
};
|
|
167
|
+
if (!step.description && !step.test_data && !step.expected_result) {
|
|
168
|
+
throw new Error("Each test step needs at least a description, test_data or expected_result");
|
|
169
|
+
}
|
|
170
|
+
return step;
|
|
171
|
+
}
|
|
172
|
+
function parseSteps(raw) {
|
|
173
|
+
if (raw === undefined || raw === null || raw === "")
|
|
174
|
+
return null;
|
|
175
|
+
const parsed = safeJsonParse(raw, "test_steps");
|
|
176
|
+
if (!Array.isArray(parsed))
|
|
177
|
+
throw new Error("test_steps must be a JSON array");
|
|
178
|
+
return parsed.map(normalizeStep);
|
|
179
|
+
}
|
|
180
|
+
// Steps are added one call each; collect failures instead of aborting
|
|
181
|
+
async function addSteps(projectId, testcaseId, steps) {
|
|
182
|
+
let created = 0;
|
|
183
|
+
const errors = [];
|
|
184
|
+
for (const [i, step] of steps.entries()) {
|
|
185
|
+
try {
|
|
186
|
+
await callApi("post", `projects/testcases/teststeps/${projectId}/${testcaseId}`, { body: step });
|
|
187
|
+
created++;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const err = axios.isAxiosError(error) ? handleApiError(error) : error;
|
|
191
|
+
errors.push(`step ${i + 1}: ${err.message}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { created, errors };
|
|
195
|
+
}
|
|
196
|
+
// The list endpoints only accept array values inside the filters JSON
|
|
197
|
+
function buildFilters(raw, extra = {}) {
|
|
198
|
+
const parsed = raw ? safeJsonParse(raw, "filters") : {};
|
|
199
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
200
|
+
throw new Error("filters must be a JSON object");
|
|
201
|
+
}
|
|
202
|
+
const merged = { ...parsed };
|
|
203
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
204
|
+
if (v !== undefined && v !== null && v !== "")
|
|
205
|
+
merged[k] = v;
|
|
206
|
+
}
|
|
207
|
+
for (const k of Object.keys(merged)) {
|
|
208
|
+
if (k === "custom_fields" || k.startsWith("custom_fields."))
|
|
209
|
+
continue;
|
|
210
|
+
if (!Array.isArray(merged[k]))
|
|
211
|
+
merged[k] = [merged[k]];
|
|
212
|
+
}
|
|
213
|
+
return Object.keys(merged).length ? JSON.stringify(merged) : undefined;
|
|
214
|
+
}
|
|
215
|
+
// Builds the create/bulk-create payload from tool args (shared by both actions)
|
|
216
|
+
async function buildTestcasePayload(projectId, tc) {
|
|
217
|
+
const data = { title: tc.title ?? tc.name };
|
|
218
|
+
const summary = tc.summary ?? tc.description;
|
|
219
|
+
if (summary)
|
|
220
|
+
data.summary = summary;
|
|
221
|
+
for (const f of ["priority", "status", "automated", "platform"]) {
|
|
222
|
+
if (tc[f])
|
|
223
|
+
data[f] = await coerceFieldValue(projectId, f, tc[f]);
|
|
224
|
+
}
|
|
225
|
+
const rawFolderId = tc.folder_id ?? tc.folderId;
|
|
226
|
+
if (rawFolderId !== undefined && rawFolderId !== null && rawFolderId !== "") {
|
|
227
|
+
const num = Number(rawFolderId);
|
|
228
|
+
if (isNaN(num))
|
|
229
|
+
throw new Error("folder_id must be a numeric folder ID");
|
|
230
|
+
data.folder_id = num;
|
|
231
|
+
}
|
|
232
|
+
if (tc.labels)
|
|
233
|
+
data.labels = toStringArray(tc.labels);
|
|
234
|
+
if (tc.features)
|
|
235
|
+
data.features = toStringArray(tc.features);
|
|
236
|
+
if (tc.environment)
|
|
237
|
+
data.environment = toStringArray(tc.environment);
|
|
238
|
+
for (const f of ["precondition", "test_data", "expected_result"]) {
|
|
239
|
+
if (tc[f])
|
|
240
|
+
data[f] = tc[f];
|
|
241
|
+
}
|
|
242
|
+
const ownerId = tc.owner_id ?? tc.ownerId;
|
|
243
|
+
if (ownerId) {
|
|
244
|
+
const num = Number(ownerId);
|
|
245
|
+
data.owner_id = isNaN(num) ? ownerId : num;
|
|
246
|
+
}
|
|
247
|
+
const customRaw = tc.custom_fields ?? tc.customFields;
|
|
248
|
+
if (customRaw) {
|
|
249
|
+
const parsed = safeJsonParse(customRaw, "custom_fields");
|
|
250
|
+
for (const key of Object.keys(parsed)) {
|
|
251
|
+
if (!Array.isArray(parsed[key]))
|
|
252
|
+
parsed[key] = [parsed[key]];
|
|
253
|
+
}
|
|
254
|
+
data.custom_fields = parsed;
|
|
255
|
+
}
|
|
256
|
+
return data;
|
|
257
|
+
}
|
|
258
|
+
function jsonResponseWithHints(data, hints) {
|
|
259
|
+
const json = JSON.stringify(data, null, 2);
|
|
260
|
+
const hintsBlock = hints.length > 0
|
|
261
|
+
? `\n\n---\nHINTS:\n${hints.map((h) => `- ${h}`).join("\n")}`
|
|
262
|
+
: "";
|
|
263
|
+
return {
|
|
264
|
+
content: [{ type: "text", text: json + hintsBlock }],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
// ─── Error enrichment ───────────────────────────────────────────────────────
|
|
268
|
+
const ERROR_HINTS = [
|
|
269
|
+
{ pattern: /Invalid value.*for field/i,
|
|
270
|
+
hint: "RECOVERY: Call get_project_structure(projectId, include='field_options') to discover allowed values for this field, then retry." },
|
|
271
|
+
{ pattern: /must be a number/i,
|
|
272
|
+
hint: "RECOVERY: This field requires a numeric ID. Call get_project_structure(projectId) to find valid IDs." },
|
|
273
|
+
{ pattern: /folder_id/i,
|
|
274
|
+
hint: "RECOVERY: folder_id must be a numeric folder ID. Call get_project_structure(projectId, include='folders') to list folders with their IDs." },
|
|
275
|
+
{ pattern: /status.*(401|unauthorized)/i,
|
|
276
|
+
hint: "RECOVERY: PAT token may be expired. User needs to regenerate it from TestKase > API Keys." },
|
|
277
|
+
{ pattern: /Status: 403/i,
|
|
278
|
+
hint: "RECOVERY: The user's project role does not allow this action (e.g. only owners and project admins can delete folders). If the project belongs to another organization, set TESTKASE_ORGANIZATION_ID." },
|
|
279
|
+
{ pattern: /priority/i,
|
|
280
|
+
hint: "RECOVERY: Priority values are project-specific. Call get_project_structure(projectId, include='field_options') to discover allowed values." },
|
|
281
|
+
];
|
|
282
|
+
function enrichErrorMessage(errorMsg) {
|
|
283
|
+
for (const { pattern, hint } of ERROR_HINTS) {
|
|
284
|
+
if (pattern.test(errorMsg)) {
|
|
285
|
+
return `${errorMsg}\n\n${hint}`;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return errorMsg;
|
|
289
|
+
}
|
|
290
|
+
// ─── Field options cache (5-min TTL) ────────────────────────────────────────
|
|
291
|
+
const fieldOptionsCache = new Map();
|
|
292
|
+
const FIELD_OPTIONS_TTL = 5 * 60 * 1000; // 5 minutes
|
|
293
|
+
async function getFieldOptions(projectId) {
|
|
294
|
+
const cached = fieldOptionsCache.get(projectId);
|
|
295
|
+
if (cached && Date.now() - cached.ts < FIELD_OPTIONS_TTL) {
|
|
296
|
+
return cached.data;
|
|
297
|
+
}
|
|
298
|
+
try {
|
|
299
|
+
const response = await callApi("get", `projects/fields/options/${projectId}`);
|
|
300
|
+
const options = response?.data || response || [];
|
|
301
|
+
fieldOptionsCache.set(projectId, { data: options, ts: Date.now() });
|
|
302
|
+
return options;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return cached?.data || [];
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function coerceFieldValue(projectId, fieldName, rawValue) {
|
|
309
|
+
if (rawValue === undefined || rawValue === null)
|
|
310
|
+
return rawValue;
|
|
311
|
+
const options = await getFieldOptions(projectId);
|
|
312
|
+
const fieldDef = options.find((f) => f.fieldName?.toLowerCase() === fieldName.toLowerCase());
|
|
313
|
+
if (!fieldDef?.values || !Array.isArray(fieldDef.values))
|
|
314
|
+
return rawValue;
|
|
315
|
+
const strValue = String(rawValue);
|
|
316
|
+
// Exact match
|
|
317
|
+
if (fieldDef.values.includes(strValue))
|
|
318
|
+
return strValue;
|
|
319
|
+
// Case-insensitive match
|
|
320
|
+
const match = fieldDef.values.find((v) => v.toLowerCase() === strValue.toLowerCase());
|
|
321
|
+
if (match)
|
|
322
|
+
return match;
|
|
323
|
+
// No match — return as-is, let API validate
|
|
324
|
+
return rawValue;
|
|
325
|
+
}
|
|
91
326
|
// ─── Param normalizer (handles camelCase / snake_case) ───────────────────────
|
|
92
327
|
function p(args, ...names) {
|
|
93
328
|
for (const n of names) {
|
|
@@ -96,6 +331,13 @@ function p(args, ...names) {
|
|
|
96
331
|
}
|
|
97
332
|
return undefined;
|
|
98
333
|
}
|
|
334
|
+
function requireArg(args, name, aliases = []) {
|
|
335
|
+
const value = p(args, name, ...aliases);
|
|
336
|
+
if (value === undefined || value === null || value === "") {
|
|
337
|
+
throw new Error(`${name} is required for this action`);
|
|
338
|
+
}
|
|
339
|
+
return value;
|
|
340
|
+
}
|
|
99
341
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
100
342
|
// TOOL DEFINITIONS (11 tools)
|
|
101
343
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -103,20 +345,21 @@ const LIST_PROJECTS_TOOL = {
|
|
|
103
345
|
name: "list_projects",
|
|
104
346
|
description: "List all projects the authenticated user has access to. " +
|
|
105
347
|
"Use this first to find a projectId, then pass it to other tools.",
|
|
348
|
+
annotations: { readOnlyHint: true },
|
|
106
349
|
inputSchema: {
|
|
107
350
|
type: "object",
|
|
108
351
|
properties: {
|
|
109
|
-
search: { type: "string", description: "Search by project name or ID" },
|
|
110
352
|
page: { type: "number", description: "Page number (default: 1)" },
|
|
111
|
-
limit: { type: "number", description: "Items per page (default:
|
|
353
|
+
limit: { type: "number", description: "Items per page (default: 12)" },
|
|
112
354
|
},
|
|
113
355
|
required: [],
|
|
114
356
|
},
|
|
115
357
|
};
|
|
116
358
|
const GET_PROJECT_STRUCTURE_TOOL = {
|
|
117
359
|
name: "get_project_structure",
|
|
118
|
-
description: "Get the structure of a project: folders, labels, and
|
|
119
|
-
"
|
|
360
|
+
description: "Get the structure of a project: folders, labels, team members, and field options (allowed values for priority, status, etc.). " +
|
|
361
|
+
"IMPORTANT: Call this before create/update to discover allowed field values, which are project-specific.",
|
|
362
|
+
annotations: { readOnlyHint: true },
|
|
120
363
|
inputSchema: {
|
|
121
364
|
type: "object",
|
|
122
365
|
properties: {
|
|
@@ -128,7 +371,7 @@ const GET_PROJECT_STRUCTURE_TOOL = {
|
|
|
128
371
|
},
|
|
129
372
|
include: {
|
|
130
373
|
type: "string",
|
|
131
|
-
description: "Comma-separated list of what to include: folders,labels,members (default: all)",
|
|
374
|
+
description: "Comma-separated list of what to include: folders,labels,members,field_options (default: all)",
|
|
132
375
|
},
|
|
133
376
|
},
|
|
134
377
|
required: ["projectId"],
|
|
@@ -136,18 +379,24 @@ const GET_PROJECT_STRUCTURE_TOOL = {
|
|
|
136
379
|
};
|
|
137
380
|
const SEARCH_TESTCASES_TOOL = {
|
|
138
381
|
name: "search_testcases",
|
|
139
|
-
description: "Search and list test cases in a project. Supports text search, filters, sorting, and pagination.",
|
|
382
|
+
description: "Search and list test cases in a project. Supports text search, folder filter, filters, sorting, and pagination.",
|
|
383
|
+
annotations: { readOnlyHint: true },
|
|
140
384
|
inputSchema: {
|
|
141
385
|
type: "object",
|
|
142
386
|
properties: {
|
|
143
387
|
projectId: { type: "string", description: "Project ID" },
|
|
144
|
-
search: { type: "string", description: "Search text (title
|
|
388
|
+
search: { type: "string", description: "Search text (ID, title, summary, field values)" },
|
|
389
|
+
folder_id: {
|
|
390
|
+
type: "number",
|
|
391
|
+
description: "Only test cases in this folder (sub-folders included). Use 0 for test cases with no folder.",
|
|
392
|
+
},
|
|
145
393
|
filters: {
|
|
146
394
|
type: "string",
|
|
147
|
-
description: 'JSON
|
|
395
|
+
description: 'JSON object of filters; every value is a list. Keys are field names (use the field_options names). ' +
|
|
396
|
+
'Example: {"status":["Active"],"priority":["High","Critical"]}. Also supports "exclude_status".',
|
|
148
397
|
},
|
|
149
398
|
page: { type: "number", description: "Page number (default: 1)" },
|
|
150
|
-
limit: { type: "number", description: "Items per page (default: 20)" },
|
|
399
|
+
limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
|
|
151
400
|
sortBy: { type: "string", description: "Field to sort by (e.g. created_at, title)" },
|
|
152
401
|
sortOrder: { type: "string", enum: ["asc", "desc"], description: "Sort direction" },
|
|
153
402
|
},
|
|
@@ -156,7 +405,8 @@ const SEARCH_TESTCASES_TOOL = {
|
|
|
156
405
|
};
|
|
157
406
|
const GET_TESTCASE_TOOL = {
|
|
158
407
|
name: "get_testcase",
|
|
159
|
-
description: "Get full details of a single test case including test steps and attachments.",
|
|
408
|
+
description: "Get full details of a single test case including test steps (with step ids) and attachments.",
|
|
409
|
+
annotations: { readOnlyHint: true },
|
|
160
410
|
inputSchema: {
|
|
161
411
|
type: "object",
|
|
162
412
|
properties: {
|
|
@@ -166,17 +416,34 @@ const GET_TESTCASE_TOOL = {
|
|
|
166
416
|
required: ["projectId", "testcaseId"],
|
|
167
417
|
},
|
|
168
418
|
};
|
|
419
|
+
const TESTCASE_FIELDS = "title, summary, precondition, priority, status, automated, test_data, expected_result, labels, features, platform, environment, custom_fields, owner_id, folder_id";
|
|
169
420
|
const MANAGE_TESTCASE_TOOL = {
|
|
170
421
|
name: "manage_testcase",
|
|
171
|
-
description: "
|
|
172
|
-
"
|
|
422
|
+
description: "IMPORTANT: Before creating/updating, call get_project_structure(projectId) to discover folder IDs and allowed field values.\n\n" +
|
|
423
|
+
"Create, update or delete test cases and their test steps.\n\n" +
|
|
424
|
+
"ACTIONS:\n" +
|
|
425
|
+
" create - Create a single test case. Params: title (required), test_steps, and any of: " + TESTCASE_FIELDS + "\n" +
|
|
426
|
+
" create_bulk - Create up to 100 test cases in one go. Params: testcases (JSON array; each item takes the same fields as create, including test_steps)\n" +
|
|
427
|
+
" update - Update one field for test case(s). Params: ids, field, value (all required)\n" +
|
|
428
|
+
" delete - PERMANENTLY delete test case(s) with their steps, history and links. Params: ids (required)\n" +
|
|
429
|
+
" add_steps - Append steps to a test case. Params: testcaseId, test_steps\n" +
|
|
430
|
+
" update_step - Change one step. Params: testcaseId, step_id, and description/test_data/expected_result\n" +
|
|
431
|
+
" delete_steps - Delete steps. Params: testcaseId, step_ids (comma-separated)\n" +
|
|
432
|
+
" reorder_steps - Set the step order. Params: testcaseId, step_ids (all step ids, in the new order)\n" +
|
|
433
|
+
"Step ids come from get_testcase.\n\n" +
|
|
434
|
+
"VALID UPDATE FIELDS: " + TESTCASE_FIELDS + "\n\n" +
|
|
435
|
+
"EXAMPLES:\n" +
|
|
436
|
+
" Create: { action: 'create', title: 'Verify login', test_steps: '[{\"description\":\"Open login page\",\"expected_result\":\"Form shown\"}]' }\n" +
|
|
437
|
+
" Update: { action: 'update', ids: 'TEST-1', field: 'priority', value: '<value from get_project_structure>' }\n" +
|
|
438
|
+
" Delete: { action: 'delete', ids: 'TEST-1,TEST-2' }",
|
|
439
|
+
annotations: { destructiveHint: true },
|
|
173
440
|
inputSchema: {
|
|
174
441
|
type: "object",
|
|
175
442
|
properties: {
|
|
176
443
|
projectId: { type: "string", description: "Project ID" },
|
|
177
444
|
action: {
|
|
178
445
|
type: "string",
|
|
179
|
-
enum: ["create", "create_bulk", "update", "delete"],
|
|
446
|
+
enum: ["create", "create_bulk", "update", "delete", "add_steps", "update_step", "delete_steps", "reorder_steps"],
|
|
180
447
|
description: "Action to perform",
|
|
181
448
|
},
|
|
182
449
|
// create params
|
|
@@ -184,39 +451,75 @@ const MANAGE_TESTCASE_TOOL = {
|
|
|
184
451
|
summary: { type: "string", description: "[create] Description/summary" },
|
|
185
452
|
priority: {
|
|
186
453
|
type: "string",
|
|
187
|
-
description: "[create] Priority
|
|
454
|
+
description: "[create] Priority. Values are project-specific — call get_project_structure(projectId, include='field_options') first to discover allowed values.",
|
|
188
455
|
},
|
|
189
|
-
folder_id: { type: "
|
|
456
|
+
folder_id: { type: "number", description: "[create] Folder ID to place the test case in" },
|
|
190
457
|
labels: {
|
|
191
458
|
type: "string",
|
|
192
459
|
description: "[create] Comma-separated label names",
|
|
193
460
|
},
|
|
194
461
|
test_steps: {
|
|
195
462
|
type: "string",
|
|
196
|
-
description: '[create] JSON array of test steps: [{"
|
|
463
|
+
description: '[create/add_steps] JSON array of test steps: [{"description":"...","test_data":"...","expected_result":"..."}]. Every step needs a description.',
|
|
464
|
+
},
|
|
465
|
+
precondition: { type: "string", description: "[create] Preconditions for the test case" },
|
|
466
|
+
status: {
|
|
467
|
+
type: "string",
|
|
468
|
+
description: "[create] Status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
|
|
469
|
+
},
|
|
470
|
+
automated: {
|
|
471
|
+
type: "string",
|
|
472
|
+
description: "[create] Automation status. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
|
|
473
|
+
},
|
|
474
|
+
test_data: { type: "string", description: "[create/update_step] Test data" },
|
|
475
|
+
expected_result: { type: "string", description: "[create/update_step] Expected result" },
|
|
476
|
+
platform: {
|
|
477
|
+
type: "string",
|
|
478
|
+
description: "[create] Platform. Values are project-specific — call get_project_structure(projectId, include='field_options') first.",
|
|
479
|
+
},
|
|
480
|
+
environment: { type: "string", description: "[create] Comma-separated environment values" },
|
|
481
|
+
features: { type: "string", description: "[create] Comma-separated feature/component names" },
|
|
482
|
+
owner_id: { type: "string", description: "[create] Owner user ID (a project member)" },
|
|
483
|
+
custom_fields: {
|
|
484
|
+
type: "string",
|
|
485
|
+
description: '[create] JSON object of custom field values (e.g. {"custom1": "value", "custom2": ["val1","val2"]})',
|
|
197
486
|
},
|
|
198
487
|
// create_bulk params
|
|
199
488
|
testcases: {
|
|
200
489
|
type: "string",
|
|
201
|
-
description: "[create_bulk] JSON array of test cases
|
|
490
|
+
description: "[create_bulk] JSON array of test cases. Each item takes the create fields, e.g. " +
|
|
491
|
+
'[{"title":"...","folder_id":12,"priority":"High","test_steps":[{"description":"...","expected_result":"..."}]}]',
|
|
202
492
|
},
|
|
203
|
-
// update params
|
|
493
|
+
// update/delete params
|
|
204
494
|
ids: {
|
|
205
495
|
type: "string",
|
|
206
|
-
description: "[update/delete]
|
|
496
|
+
description: "[update/delete] Test case IDs, comma-separated (e.g. 'TEST-1' or 'TEST-1,TEST-2')",
|
|
207
497
|
},
|
|
208
498
|
field: {
|
|
209
499
|
type: "string",
|
|
210
|
-
|
|
500
|
+
enum: TESTCASE_FIELDS.split(", "),
|
|
501
|
+
description: "[update] Field to update. For priority/status/automated/platform/environment, call get_project_structure(include='field_options') to get allowed values.",
|
|
211
502
|
},
|
|
212
|
-
value: { type: "string", description: "[update] New value for
|
|
503
|
+
value: { type: "string", description: "[update] New value. String for most fields. For labels/features/environment: comma-separated. For custom_fields: JSON object (e.g. {\"fieldName\": \"value\"})" },
|
|
504
|
+
// step params
|
|
505
|
+
testcaseId: { type: "string", description: "[add_steps/update_step/delete_steps/reorder_steps] Test case ID (e.g. TEST-123)" },
|
|
506
|
+
step_id: { type: "number", description: "[update_step] Step ID to change" },
|
|
507
|
+
description: { type: "string", description: "[update_step] Step description" },
|
|
508
|
+
step_ids: { type: "string", description: "[delete_steps/reorder_steps] Comma-separated step IDs" },
|
|
213
509
|
},
|
|
214
510
|
required: ["projectId", "action"],
|
|
215
511
|
},
|
|
216
512
|
};
|
|
217
513
|
const MANAGE_FOLDER_TOOL = {
|
|
218
514
|
name: "manage_folder",
|
|
219
|
-
description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN)
|
|
515
|
+
description: "Create, rename, move, or delete folders. Works across all sections (TESTCASE, TEST_CYCLE, TEST_PLAN).\n\n" +
|
|
516
|
+
"ACTIONS:\n" +
|
|
517
|
+
" create - Create a folder. Params: name (required), section, parentId\n" +
|
|
518
|
+
" rename - Rename a folder. Params: folderId (required), name (required)\n" +
|
|
519
|
+
" move - Move a folder. Params: folderId (required), parentId (omit or null for root)\n" +
|
|
520
|
+
" delete - PERMANENTLY delete a folder, all its sub-folders AND every test case / cycle / plan inside them. " +
|
|
521
|
+
"Only owners and project admins can do this. Confirm with the user first. Params: folderId (required)",
|
|
522
|
+
annotations: { destructiveHint: true },
|
|
220
523
|
inputSchema: {
|
|
221
524
|
type: "object",
|
|
222
525
|
properties: {
|
|
@@ -247,23 +550,34 @@ const MANAGE_FOLDER_TOOL = {
|
|
|
247
550
|
const SEARCH_TEST_CYCLES_TOOL = {
|
|
248
551
|
name: "search_test_cycles",
|
|
249
552
|
description: "List and search test cycles in a project with execution progress.",
|
|
553
|
+
annotations: { readOnlyHint: true },
|
|
250
554
|
inputSchema: {
|
|
251
555
|
type: "object",
|
|
252
556
|
properties: {
|
|
253
557
|
projectId: { type: "string", description: "Project ID" },
|
|
254
558
|
search: { type: "string", description: "Search by title" },
|
|
255
559
|
folderId: { type: "number", description: "Filter by folder ID" },
|
|
256
|
-
status: { type: "string", description: "Filter by status
|
|
560
|
+
status: { type: "string", enum: ["open", "in_progress", "completed"], description: "Filter by status" },
|
|
257
561
|
page: { type: "number", description: "Page number (default: 1)" },
|
|
258
|
-
limit: { type: "number", description: "Items per page (default: 20)" },
|
|
562
|
+
limit: { type: "number", description: "Items per page (default: 20, max: 100)" },
|
|
259
563
|
},
|
|
260
564
|
required: ["projectId"],
|
|
261
565
|
},
|
|
262
566
|
};
|
|
263
567
|
const MANAGE_TEST_CYCLE_TOOL = {
|
|
264
568
|
name: "manage_test_cycle",
|
|
265
|
-
description: "Manage test cycles: create, update, delete, get details, list/link/unlink/assign test cases
|
|
266
|
-
"
|
|
569
|
+
description: "Manage test cycles: create, update, delete, get details, list/link/unlink/assign test cases.\n\n" +
|
|
570
|
+
"ACTIONS:\n" +
|
|
571
|
+
" create - Create a cycle. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
|
|
572
|
+
" update - Update a cycle. Params: cycleId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
|
|
573
|
+
" delete - Delete cycle(s). Params: ids (required, comma-separated)\n" +
|
|
574
|
+
" get_details - Get cycle details. Params: cycleId (required)\n" +
|
|
575
|
+
" get_testcases - List test cases in cycle. Params: cycleId (required), search, page, limit\n" +
|
|
576
|
+
" link_testcases - Link test cases to cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
|
|
577
|
+
" unlink_testcases - Unlink test cases from cycle. Params: cycleId (required), testcase_ids (required, comma-separated)\n" +
|
|
578
|
+
" assign_testcases - Assign test cases to user. Params: cycleId (required), testcase_ids (required), assignee_id (required)\n\n" +
|
|
579
|
+
"VALID STATUSES: open, in_progress, completed",
|
|
580
|
+
annotations: { destructiveHint: true },
|
|
267
581
|
inputSchema: {
|
|
268
582
|
type: "object",
|
|
269
583
|
properties: {
|
|
@@ -292,7 +606,8 @@ const MANAGE_TEST_CYCLE_TOOL = {
|
|
|
292
606
|
summary: { type: "string", description: "[create/update] Description" },
|
|
293
607
|
status: {
|
|
294
608
|
type: "string",
|
|
295
|
-
|
|
609
|
+
enum: ["open", "in_progress", "completed"],
|
|
610
|
+
description: "[create/update] Status",
|
|
296
611
|
},
|
|
297
612
|
planned_start_date: {
|
|
298
613
|
type: "string",
|
|
@@ -332,7 +647,8 @@ const EXECUTE_TESTS_TOOL = {
|
|
|
332
647
|
name: "execute_tests",
|
|
333
648
|
description: "Record test execution results for test cases in a test cycle. " +
|
|
334
649
|
"Pass a single result or an array of results. " +
|
|
335
|
-
"Valid execution statuses: pass, fail, blocked, not_executed
|
|
650
|
+
"Valid execution statuses: pass, fail, blocked, not_executed. " +
|
|
651
|
+
"The test cases must already be linked to the cycle (manage_test_cycle link_testcases).",
|
|
336
652
|
inputSchema: {
|
|
337
653
|
type: "object",
|
|
338
654
|
properties: {
|
|
@@ -343,14 +659,31 @@ const EXECUTE_TESTS_TOOL = {
|
|
|
343
659
|
description: 'JSON array of results: [{"testcase_id":"TEST-1","execution_status":"pass","actual_result":"Worked","environment":"staging"}]. ' +
|
|
344
660
|
"Each object must have testcase_id and execution_status. actual_result and environment are optional.",
|
|
345
661
|
},
|
|
662
|
+
mode: {
|
|
663
|
+
type: "string",
|
|
664
|
+
enum: ["manual", "automation"],
|
|
665
|
+
description: "manual (default): each result is recorded like a tester setting it in the app — the new status replaces the old one. " +
|
|
666
|
+
"automation: recorded as a CI/automation run, where a failure from any earlier automation run keeps the case failed. Use only for CI results.",
|
|
667
|
+
},
|
|
346
668
|
},
|
|
347
669
|
required: ["projectId", "cycleId", "results"],
|
|
348
670
|
},
|
|
349
671
|
};
|
|
350
672
|
const MANAGE_TEST_PLAN_TOOL = {
|
|
351
673
|
name: "manage_test_plan",
|
|
352
|
-
description: "Manage test plans: create, update, delete, list, get details, link/unlink cycles, get cycles, get test cases
|
|
353
|
-
"
|
|
674
|
+
description: "Manage test plans: create, update, delete, list, get details, link/unlink cycles, get cycles, get test cases.\n\n" +
|
|
675
|
+
"ACTIONS:\n" +
|
|
676
|
+
" create - Create a plan. Params: title (required), summary, status, planned_start_date, planned_end_date, folder_id\n" +
|
|
677
|
+
" update - Update a plan. Params: planId (required), plus fields to change (title, summary, status, dates, folder_id)\n" +
|
|
678
|
+
" delete - Delete plan(s). Params: ids (required, comma-separated)\n" +
|
|
679
|
+
" list - List all plans. Params: search, page, limit\n" +
|
|
680
|
+
" get_details - Get plan details. Params: planId (required)\n" +
|
|
681
|
+
" link_cycles - Link cycles to plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
|
|
682
|
+
" unlink_cycles - Unlink cycles from plan. Params: planId (required), cycle_ids (required, comma-separated)\n" +
|
|
683
|
+
" get_cycles - List cycles in plan. Params: planId (required), search, page, limit\n" +
|
|
684
|
+
" get_testcases - List test cases in plan. Params: planId (required), search, page, limit\n\n" +
|
|
685
|
+
"VALID STATUSES: open, in_progress, completed",
|
|
686
|
+
annotations: { destructiveHint: true },
|
|
354
687
|
inputSchema: {
|
|
355
688
|
type: "object",
|
|
356
689
|
properties: {
|
|
@@ -378,7 +711,7 @@ const MANAGE_TEST_PLAN_TOOL = {
|
|
|
378
711
|
// create/update fields
|
|
379
712
|
title: { type: "string", description: "[create/update] Plan title" },
|
|
380
713
|
summary: { type: "string", description: "[create/update] Description" },
|
|
381
|
-
status: { type: "string", description: "[create/update] Status" },
|
|
714
|
+
status: { type: "string", enum: ["open", "in_progress", "completed"], description: "[create/update] Status" },
|
|
382
715
|
planned_start_date: {
|
|
383
716
|
type: "string",
|
|
384
717
|
description: "[create/update] Start date (YYYY-MM-DD)",
|
|
@@ -408,7 +741,8 @@ const MANAGE_TEST_PLAN_TOOL = {
|
|
|
408
741
|
};
|
|
409
742
|
const GET_REPORT_TOOL = {
|
|
410
743
|
name: "get_report",
|
|
411
|
-
description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and
|
|
744
|
+
description: "Get reports and analytics for a project. Covers execution, coverage, trends, team, defects, and insights " +
|
|
745
|
+
"(the insight reports are computed from your data; they do not use AI credits). " +
|
|
412
746
|
"Available report types: execution_summary, execution_by_cycle, execution_by_tester, execution_by_priority, " +
|
|
413
747
|
"execution_by_environment, execution_by_folder, requirement_coverage, traceability_matrix, " +
|
|
414
748
|
"failed_requirements, uncovered_requirements, testcase_coverage, unlinked_testcases, " +
|
|
@@ -420,6 +754,7 @@ const GET_REPORT_TOOL = {
|
|
|
420
754
|
"testcase_quality, predictive_failure, smart_prioritization, tester_effectiveness, " +
|
|
421
755
|
"stale_tests, defect_hotspots, cycle_health, suite_optimization, " +
|
|
422
756
|
"requirement_risk_matrix, execution_velocity, project_health.",
|
|
757
|
+
annotations: { readOnlyHint: true },
|
|
423
758
|
inputSchema: {
|
|
424
759
|
type: "object",
|
|
425
760
|
properties: {
|
|
@@ -428,14 +763,15 @@ const GET_REPORT_TOOL = {
|
|
|
428
763
|
type: "string",
|
|
429
764
|
description: "Report type (see description for full list)",
|
|
430
765
|
},
|
|
431
|
-
|
|
766
|
+
test_cycle_id: {
|
|
432
767
|
type: "string",
|
|
433
|
-
description: "
|
|
768
|
+
description: "Only this test cycle (one ID, e.g. TCYCLE-3)",
|
|
434
769
|
},
|
|
435
|
-
|
|
770
|
+
test_plan_id: {
|
|
436
771
|
type: "string",
|
|
437
|
-
description: "
|
|
772
|
+
description: "Only this test plan (one ID, e.g. TPLAN-2)",
|
|
438
773
|
},
|
|
774
|
+
folder_id: { type: "number", description: "Only this folder" },
|
|
439
775
|
startDate: { type: "string", description: "Start date filter (YYYY-MM-DD)" },
|
|
440
776
|
endDate: { type: "string", description: "End date filter (YYYY-MM-DD)" },
|
|
441
777
|
granularity: {
|
|
@@ -512,20 +848,22 @@ const REPORT_TYPE_MAP = {
|
|
|
512
848
|
async function handleListProjects(args) {
|
|
513
849
|
const data = await callApi("get", "projects/user/my-projects", {
|
|
514
850
|
query: {
|
|
515
|
-
search: p(args, "search"),
|
|
516
851
|
page: p(args, "page"),
|
|
517
852
|
limit: p(args, "limit"),
|
|
518
853
|
},
|
|
519
854
|
});
|
|
520
|
-
return
|
|
855
|
+
return jsonResponseWithHints(data, [
|
|
856
|
+
"Next: call get_project_structure(projectId) to see folders, labels, and allowed field values.",
|
|
857
|
+
]);
|
|
521
858
|
}
|
|
522
859
|
// 2. get_project_structure
|
|
523
860
|
async function handleGetProjectStructure(args) {
|
|
524
861
|
const projectId = p(args, "projectId", "project_id");
|
|
525
862
|
if (!projectId)
|
|
526
863
|
throw new Error("projectId is required");
|
|
864
|
+
validateId(projectId, "projectId");
|
|
527
865
|
const section = p(args, "section") || "TESTCASE";
|
|
528
|
-
const includeRaw = p(args, "include") || "folders,labels,members";
|
|
866
|
+
const includeRaw = p(args, "include") || "folders,labels,members,field_options";
|
|
529
867
|
const includes = includeRaw.split(",").map((s) => s.trim().toLowerCase());
|
|
530
868
|
const orgId = await getOrganizationId();
|
|
531
869
|
const result = {};
|
|
@@ -547,24 +885,46 @@ async function handleGetProjectStructure(args) {
|
|
|
547
885
|
result.members = data;
|
|
548
886
|
}));
|
|
549
887
|
}
|
|
888
|
+
if (includes.includes("field_options")) {
|
|
889
|
+
promises.push(callApi("get", `projects/fields/options/${projectId}`).then((data) => {
|
|
890
|
+
result.field_options = data?.data || data;
|
|
891
|
+
}));
|
|
892
|
+
}
|
|
550
893
|
await Promise.all(promises);
|
|
551
|
-
return
|
|
894
|
+
return jsonResponseWithHints(result, [
|
|
895
|
+
"Use the field_options values above when creating/updating test cases.",
|
|
896
|
+
]);
|
|
552
897
|
}
|
|
553
898
|
// 3. search_testcases
|
|
554
899
|
async function handleSearchTestcases(args) {
|
|
555
900
|
const projectId = p(args, "projectId", "project_id");
|
|
556
901
|
if (!projectId)
|
|
557
902
|
throw new Error("projectId is required");
|
|
903
|
+
validateId(projectId, "projectId");
|
|
904
|
+
const folderId = p(args, "folder_id", "folderId");
|
|
905
|
+
if (folderId !== undefined && folderId !== null && folderId !== "" && isNaN(Number(folderId))) {
|
|
906
|
+
throw new Error("folder_id must be a numeric folder ID");
|
|
907
|
+
}
|
|
908
|
+
const filters = buildFilters(p(args, "filters"), {
|
|
909
|
+
folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : [Number(folderId)],
|
|
910
|
+
});
|
|
558
911
|
const data = await callApi("get", `projects/testcases/get-testcase/${projectId}`, {
|
|
559
912
|
query: {
|
|
560
913
|
search: p(args, "search"),
|
|
561
|
-
filters
|
|
914
|
+
filters,
|
|
562
915
|
page: p(args, "page"),
|
|
563
916
|
limit: p(args, "limit"),
|
|
564
917
|
sortBy: p(args, "sortBy", "sort_by"),
|
|
565
918
|
sortOrder: p(args, "sortOrder", "sort_order"),
|
|
566
919
|
},
|
|
567
920
|
});
|
|
921
|
+
const baseUrl = getFrontendBaseUrl();
|
|
922
|
+
if (Array.isArray(data?.data)) {
|
|
923
|
+
data.data.forEach((tc) => {
|
|
924
|
+
if (tc.id)
|
|
925
|
+
tc.url = `${baseUrl}/${projectId}/testcases/${tc.id}`;
|
|
926
|
+
});
|
|
927
|
+
}
|
|
568
928
|
return jsonResponse(data);
|
|
569
929
|
}
|
|
570
930
|
// 4. get_testcase
|
|
@@ -575,7 +935,12 @@ async function handleGetTestcase(args) {
|
|
|
575
935
|
throw new Error("projectId is required");
|
|
576
936
|
if (!testcaseId)
|
|
577
937
|
throw new Error("testcaseId is required");
|
|
938
|
+
validateId(projectId, "projectId");
|
|
939
|
+
validateId(testcaseId, "testcaseId");
|
|
578
940
|
const data = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${testcaseId}`);
|
|
941
|
+
if (data?.data?.testcase) {
|
|
942
|
+
data.data.testcase.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${testcaseId}`;
|
|
943
|
+
}
|
|
579
944
|
return jsonResponse(data);
|
|
580
945
|
}
|
|
581
946
|
// 5. manage_testcase
|
|
@@ -586,88 +951,78 @@ async function handleManageTestcase(args) {
|
|
|
586
951
|
throw new Error("projectId is required");
|
|
587
952
|
if (!action)
|
|
588
953
|
throw new Error("action is required");
|
|
954
|
+
validateId(projectId, "projectId");
|
|
589
955
|
const orgId = await getOrganizationId();
|
|
590
956
|
switch (action) {
|
|
591
957
|
case "create": {
|
|
592
958
|
const title = p(args, "title", "name");
|
|
593
959
|
if (!title)
|
|
594
960
|
throw new Error("title is required for create action");
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
if (p(args, "priority"))
|
|
599
|
-
testcaseData.priority = p(args, "priority");
|
|
600
|
-
if (p(args, "folder_id", "folderId"))
|
|
601
|
-
testcaseData.folder_id = p(args, "folder_id", "folderId");
|
|
602
|
-
const labelsRaw = p(args, "labels");
|
|
603
|
-
if (labelsRaw) {
|
|
604
|
-
testcaseData.labels =
|
|
605
|
-
typeof labelsRaw === "string"
|
|
606
|
-
? labelsRaw.split(",").map((l) => l.trim())
|
|
607
|
-
: labelsRaw;
|
|
608
|
-
}
|
|
609
|
-
// Parse test_steps
|
|
610
|
-
const stepsRaw = p(args, "test_steps", "testSteps", "steps");
|
|
611
|
-
const testSteps = stepsRaw
|
|
612
|
-
? typeof stepsRaw === "string"
|
|
613
|
-
? JSON.parse(stepsRaw)
|
|
614
|
-
: stepsRaw
|
|
615
|
-
: null;
|
|
616
|
-
// Step 1: Create testcase
|
|
961
|
+
// Validate steps before creating anything, so a bad step can't leave a half-made test case
|
|
962
|
+
const testSteps = parseSteps(p(args, "test_steps", "testSteps", "steps"));
|
|
963
|
+
const testcaseData = await buildTestcasePayload(projectId, { ...args, title });
|
|
617
964
|
const createResult = await callApi("post", `projects/testcases/create-testcase/${projectId}`, { body: testcaseData });
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
965
|
+
const tcId = createResult?.testcaseId || createResult?.data?.id;
|
|
966
|
+
const hints = [
|
|
967
|
+
"To update fields, use manage_testcase(action='update', ids='<id>', field='<name>', value='<value>')",
|
|
968
|
+
];
|
|
969
|
+
if (tcId) {
|
|
970
|
+
createResult.url = `${getFrontendBaseUrl()}/${projectId}/testcases/${tcId}`;
|
|
971
|
+
if (testSteps?.length) {
|
|
972
|
+
const steps = await addSteps(projectId, tcId, testSteps);
|
|
973
|
+
createResult.steps_created = steps.created;
|
|
974
|
+
if (steps.errors.length) {
|
|
975
|
+
createResult.step_errors = steps.errors;
|
|
976
|
+
hints.push(`The test case ${tcId} WAS created; do not create it again. Add the missing steps with manage_testcase(action='add_steps', testcaseId='${tcId}').`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
625
979
|
}
|
|
626
|
-
return
|
|
980
|
+
return jsonResponseWithHints(createResult, hints);
|
|
627
981
|
}
|
|
628
982
|
case "create_bulk": {
|
|
629
983
|
const rawTestcases = p(args, "testcases", "test_cases", "items");
|
|
630
984
|
if (!rawTestcases)
|
|
631
985
|
throw new Error("testcases is required for create_bulk action");
|
|
632
|
-
const testcases =
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
986
|
+
const testcases = safeJsonParse(rawTestcases, "testcases");
|
|
987
|
+
if (!Array.isArray(testcases) || testcases.length === 0) {
|
|
988
|
+
throw new Error("testcases must be a non-empty JSON array");
|
|
989
|
+
}
|
|
990
|
+
// Validate everything before creating anything
|
|
991
|
+
const items = [];
|
|
992
|
+
for (const [i, tc] of testcases.entries()) {
|
|
993
|
+
if (!tc?.title && !tc?.name)
|
|
994
|
+
throw new Error(`testcases[${i}]: title is required`);
|
|
995
|
+
try {
|
|
996
|
+
items.push({
|
|
997
|
+
testcase: await buildTestcasePayload(projectId, tc),
|
|
998
|
+
test_steps: parseSteps(tc.test_steps ?? tc.testSteps ?? tc.steps),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
throw new Error(`testcases[${i}]: ${error.message}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
const bulkResult = await callApi("post", `projects/testcases/create-bulk-testcase/${projectId}`, { body: { testcases: items.map((t) => t.testcase) } });
|
|
649
1006
|
// Add test steps for each created testcase
|
|
650
1007
|
const results = bulkResult?.results || [];
|
|
651
1008
|
let stepsCreated = 0;
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
const steps =
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
stepsCreated++;
|
|
663
|
-
}
|
|
664
|
-
catch {
|
|
665
|
-
// Non-fatal: steps failed but testcase was created
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
}
|
|
1009
|
+
const stepErrors = {};
|
|
1010
|
+
for (const r of results) {
|
|
1011
|
+
const steps = items[r.index ?? results.indexOf(r)]?.test_steps;
|
|
1012
|
+
const tcId = r.testcaseId || r.id;
|
|
1013
|
+
if (r.status !== "success" || !tcId || !steps?.length)
|
|
1014
|
+
continue;
|
|
1015
|
+
const res = await addSteps(projectId, tcId, steps);
|
|
1016
|
+
stepsCreated += res.created;
|
|
1017
|
+
if (res.errors.length)
|
|
1018
|
+
stepErrors[tcId] = res.errors;
|
|
669
1019
|
}
|
|
670
|
-
|
|
1020
|
+
const response = { ...bulkResult, steps_created: stepsCreated };
|
|
1021
|
+
if (Object.keys(stepErrors).length)
|
|
1022
|
+
response.step_errors = stepErrors;
|
|
1023
|
+
return jsonResponseWithHints(response, Object.keys(stepErrors).length
|
|
1024
|
+
? ["Some steps failed (see step_errors). The test cases WERE created; add the missing steps with manage_testcase(action='add_steps')."]
|
|
1025
|
+
: []);
|
|
671
1026
|
}
|
|
672
1027
|
case "update": {
|
|
673
1028
|
const ids = p(args, "ids", "testcase_ids", "testcaseIds");
|
|
@@ -679,9 +1034,36 @@ async function handleManageTestcase(args) {
|
|
|
679
1034
|
throw new Error("field is required for update action");
|
|
680
1035
|
if (value === undefined)
|
|
681
1036
|
throw new Error("value is required for update action");
|
|
682
|
-
|
|
1037
|
+
if (!TESTCASE_FIELDS.split(", ").includes(field)) {
|
|
1038
|
+
throw new Error(`Invalid field: ${field}. Valid fields: ${TESTCASE_FIELDS}`);
|
|
1039
|
+
}
|
|
1040
|
+
const idsStr = toIdList(ids);
|
|
1041
|
+
// Coerce field values for project-specific fields
|
|
1042
|
+
const coercibleFields = ["priority", "status", "automated", "platform"];
|
|
1043
|
+
// Parse value for fields that expect non-string types
|
|
1044
|
+
let parsedValue = value;
|
|
1045
|
+
if (field === "folder_id" || field === "owner_id") {
|
|
1046
|
+
const num = Number(value);
|
|
1047
|
+
parsedValue = isNaN(num) ? value : num;
|
|
1048
|
+
}
|
|
1049
|
+
else if (coercibleFields.includes(field)) {
|
|
1050
|
+
parsedValue = await coerceFieldValue(projectId, field, value);
|
|
1051
|
+
}
|
|
1052
|
+
else if (field === "labels" || field === "features" || field === "environment") {
|
|
1053
|
+
parsedValue = typeof value === "string" ? value.split(",").map((s) => s.trim()).filter(Boolean) : value;
|
|
1054
|
+
}
|
|
1055
|
+
else if (field === "custom_fields") {
|
|
1056
|
+
const parsed = safeJsonParse(value, "custom_fields");
|
|
1057
|
+
// Backend requires all custom field values as arrays
|
|
1058
|
+
for (const key of Object.keys(parsed)) {
|
|
1059
|
+
if (!Array.isArray(parsed[key])) {
|
|
1060
|
+
parsed[key] = [parsed[key]];
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
parsedValue = parsed;
|
|
1064
|
+
}
|
|
683
1065
|
const data = await callApi("patch", `projects/testcases/update-field/${projectId}`, {
|
|
684
|
-
body: { ids:
|
|
1066
|
+
body: { ids: idsStr, [field]: parsedValue },
|
|
685
1067
|
});
|
|
686
1068
|
return jsonResponse(data);
|
|
687
1069
|
}
|
|
@@ -689,14 +1071,63 @@ async function handleManageTestcase(args) {
|
|
|
689
1071
|
const ids = p(args, "ids", "testcase_ids", "testcaseIds");
|
|
690
1072
|
if (!ids)
|
|
691
1073
|
throw new Error("ids is required for delete action");
|
|
692
|
-
const idList = typeof ids === "string" ? ids.split(",").map((s) => s.trim()) : ids;
|
|
693
1074
|
const data = await callApi("delete", `projects/testcases/delete-testcase/${projectId}`, {
|
|
694
|
-
|
|
1075
|
+
query: { ids: toIdList(ids) },
|
|
1076
|
+
});
|
|
1077
|
+
return jsonResponse(data);
|
|
1078
|
+
}
|
|
1079
|
+
case "add_steps": {
|
|
1080
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1081
|
+
const steps = parseSteps(p(args, "test_steps", "testSteps", "steps"));
|
|
1082
|
+
if (!steps?.length)
|
|
1083
|
+
throw new Error("test_steps is required for add_steps action");
|
|
1084
|
+
const res = await addSteps(projectId, tcId, steps);
|
|
1085
|
+
if (res.created === 0)
|
|
1086
|
+
throw new Error(`No steps were added:\n${res.errors.join("\n")}`);
|
|
1087
|
+
return jsonResponse({ testcaseId: tcId, steps_created: res.created, step_errors: res.errors });
|
|
1088
|
+
}
|
|
1089
|
+
case "update_step": {
|
|
1090
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1091
|
+
const stepId = Number(requireArg(args, "step_id", ["stepId"]));
|
|
1092
|
+
if (isNaN(stepId))
|
|
1093
|
+
throw new Error("step_id must be a number");
|
|
1094
|
+
const body = { stepId };
|
|
1095
|
+
for (const f of ["description", "test_data", "expected_result"]) {
|
|
1096
|
+
if (args[f] !== undefined)
|
|
1097
|
+
body[f] = args[f];
|
|
1098
|
+
}
|
|
1099
|
+
// The backend requires a description string even when it is unchanged
|
|
1100
|
+
if (body.description === undefined) {
|
|
1101
|
+
const detail = await callApi("get", `projects/testcases/get-testcase-detail/${projectId}/${tcId}`);
|
|
1102
|
+
const existing = (detail?.data?.steps || []).find((st) => Number(st.id) === stepId);
|
|
1103
|
+
if (!existing)
|
|
1104
|
+
throw new Error(`Step ${stepId} not found on ${tcId}`);
|
|
1105
|
+
body.description = existing.description ?? "";
|
|
1106
|
+
}
|
|
1107
|
+
const data = await callApi("post", `projects/testcases/teststeps/${projectId}/${tcId}`, { body });
|
|
1108
|
+
return jsonResponse(data);
|
|
1109
|
+
}
|
|
1110
|
+
case "delete_steps": {
|
|
1111
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1112
|
+
const stepIds = toIdList(requireArg(args, "step_ids", ["stepIds"]));
|
|
1113
|
+
const data = await callApi("delete", `projects/testcases/teststeps/${tcId}`, {
|
|
1114
|
+
query: { projectId },
|
|
1115
|
+
body: { stepIds },
|
|
1116
|
+
});
|
|
1117
|
+
return jsonResponse(data);
|
|
1118
|
+
}
|
|
1119
|
+
case "reorder_steps": {
|
|
1120
|
+
const tcId = validateId(requireArg(args, "testcaseId", ["testcase_id", "id"]), "testcaseId");
|
|
1121
|
+
const stepIds = toStringArray(requireArg(args, "step_ids", ["stepIds"])).map(Number);
|
|
1122
|
+
if (stepIds.some(isNaN))
|
|
1123
|
+
throw new Error("step_ids must be numeric step IDs");
|
|
1124
|
+
const data = await callApi("put", `projects/testcases/teststeps/${projectId}/${tcId}/reorder`, {
|
|
1125
|
+
body: { stepIds },
|
|
695
1126
|
});
|
|
696
1127
|
return jsonResponse(data);
|
|
697
1128
|
}
|
|
698
1129
|
default:
|
|
699
|
-
throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete`);
|
|
1130
|
+
throw new Error(`Unknown action: ${action}. Use: create, create_bulk, update, delete, add_steps, update_step, delete_steps, reorder_steps`);
|
|
700
1131
|
}
|
|
701
1132
|
}
|
|
702
1133
|
// 6. manage_folder
|
|
@@ -707,6 +1138,7 @@ async function handleManageFolder(args) {
|
|
|
707
1138
|
throw new Error("projectId is required");
|
|
708
1139
|
if (!action)
|
|
709
1140
|
throw new Error("action is required");
|
|
1141
|
+
validateId(projectId, "projectId");
|
|
710
1142
|
const section = p(args, "section") || "TESTCASE";
|
|
711
1143
|
const orgId = await getOrganizationId();
|
|
712
1144
|
switch (action) {
|
|
@@ -722,7 +1154,7 @@ async function handleManageFolder(args) {
|
|
|
722
1154
|
};
|
|
723
1155
|
const parentId = p(args, "parentId", "parent_id");
|
|
724
1156
|
if (parentId)
|
|
725
|
-
body.parentId = parentId;
|
|
1157
|
+
body.parentId = Number(parentId);
|
|
726
1158
|
const data = await callApi("post", "folders/create-folders", { body, skipOrgId: true });
|
|
727
1159
|
return jsonResponse(data);
|
|
728
1160
|
}
|
|
@@ -733,6 +1165,7 @@ async function handleManageFolder(args) {
|
|
|
733
1165
|
throw new Error("folderId is required for rename action");
|
|
734
1166
|
if (!name)
|
|
735
1167
|
throw new Error("name is required for rename action");
|
|
1168
|
+
validateId(folderId, "folderId");
|
|
736
1169
|
const data = await callApi("patch", `folders/${folderId}`, {
|
|
737
1170
|
query: { projectId, section },
|
|
738
1171
|
body: { name },
|
|
@@ -743,10 +1176,14 @@ async function handleManageFolder(args) {
|
|
|
743
1176
|
const folderId = p(args, "folderId", "folder_id", "id");
|
|
744
1177
|
if (!folderId)
|
|
745
1178
|
throw new Error("folderId is required for move action");
|
|
1179
|
+
validateId(folderId, "folderId");
|
|
746
1180
|
const parentId = p(args, "parentId", "parent_id");
|
|
1181
|
+
const newParentId = parentId === undefined || parentId === null || parentId === "" ? null : Number(parentId);
|
|
1182
|
+
if (newParentId !== null && isNaN(newParentId))
|
|
1183
|
+
throw new Error("parentId must be a numeric folder ID");
|
|
747
1184
|
const data = await callApi("patch", `folders/${folderId}/move`, {
|
|
748
1185
|
query: { projectId, section },
|
|
749
|
-
body: {
|
|
1186
|
+
body: { newParentId },
|
|
750
1187
|
});
|
|
751
1188
|
return jsonResponse(data);
|
|
752
1189
|
}
|
|
@@ -754,6 +1191,7 @@ async function handleManageFolder(args) {
|
|
|
754
1191
|
const folderId = p(args, "folderId", "folder_id", "id");
|
|
755
1192
|
if (!folderId)
|
|
756
1193
|
throw new Error("folderId is required for delete action");
|
|
1194
|
+
validateId(folderId, "folderId");
|
|
757
1195
|
const data = await callApi("delete", `folders/delete/${folderId}`, {
|
|
758
1196
|
query: { projectId, section },
|
|
759
1197
|
});
|
|
@@ -768,15 +1206,28 @@ async function handleSearchTestCycles(args) {
|
|
|
768
1206
|
const projectId = p(args, "projectId", "project_id");
|
|
769
1207
|
if (!projectId)
|
|
770
1208
|
throw new Error("projectId is required");
|
|
1209
|
+
validateId(projectId, "projectId");
|
|
1210
|
+
const folderId = p(args, "folderId", "folder_id");
|
|
1211
|
+
const filters = buildFilters(undefined, {
|
|
1212
|
+
status: p(args, "status"),
|
|
1213
|
+
folder_id: folderId === undefined || folderId === null || folderId === "" ? undefined : Number(folderId),
|
|
1214
|
+
});
|
|
771
1215
|
const data = await callApi("get", `projects/test-cycles/get-test-cycles/${projectId}`, {
|
|
772
1216
|
query: {
|
|
773
1217
|
search: p(args, "search"),
|
|
774
|
-
|
|
775
|
-
status: p(args, "status"),
|
|
1218
|
+
filters,
|
|
776
1219
|
page: p(args, "page"),
|
|
777
1220
|
limit: p(args, "limit"),
|
|
778
1221
|
},
|
|
779
1222
|
});
|
|
1223
|
+
const baseUrl = getFrontendBaseUrl();
|
|
1224
|
+
if (Array.isArray(data?.data)) {
|
|
1225
|
+
data.data.forEach((cycle) => {
|
|
1226
|
+
const id = cycle.test_cycle_id || cycle.id;
|
|
1227
|
+
if (id)
|
|
1228
|
+
cycle.url = `${baseUrl}/${projectId}/testcycles/${id}`;
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
780
1231
|
return jsonResponse(data);
|
|
781
1232
|
}
|
|
782
1233
|
// 8. manage_test_cycle
|
|
@@ -787,6 +1238,7 @@ async function handleManageTestCycle(args) {
|
|
|
787
1238
|
throw new Error("projectId is required");
|
|
788
1239
|
if (!action)
|
|
789
1240
|
throw new Error("action is required");
|
|
1241
|
+
validateId(projectId, "projectId");
|
|
790
1242
|
switch (action) {
|
|
791
1243
|
case "create": {
|
|
792
1244
|
const title = p(args, "title");
|
|
@@ -802,7 +1254,7 @@ async function handleManageTestCycle(args) {
|
|
|
802
1254
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
803
1255
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
804
1256
|
if (p(args, "folder_id", "folderId"))
|
|
805
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1257
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
806
1258
|
const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
|
|
807
1259
|
return jsonResponse(data);
|
|
808
1260
|
}
|
|
@@ -822,7 +1274,7 @@ async function handleManageTestCycle(args) {
|
|
|
822
1274
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
823
1275
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
824
1276
|
if (p(args, "folder_id", "folderId"))
|
|
825
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1277
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
826
1278
|
const data = await callApi("post", `projects/test-cycles/create-test-cycle/${projectId}`, { body });
|
|
827
1279
|
return jsonResponse(data);
|
|
828
1280
|
}
|
|
@@ -830,21 +1282,26 @@ async function handleManageTestCycle(args) {
|
|
|
830
1282
|
const ids = p(args, "ids", "cycleId", "cycle_id");
|
|
831
1283
|
if (!ids)
|
|
832
1284
|
throw new Error("ids (or cycleId) is required for delete action");
|
|
833
|
-
const
|
|
834
|
-
const data = await callApi("delete", `projects/test-cycles/delete-test-cycle/${projectId}`, { body: { ids:
|
|
1285
|
+
const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
|
|
1286
|
+
const data = await callApi("delete", `projects/test-cycles/delete-test-cycle/${projectId}`, { body: { ids: idsStr } });
|
|
835
1287
|
return jsonResponse(data);
|
|
836
1288
|
}
|
|
837
1289
|
case "get_details": {
|
|
838
1290
|
const cycleId = p(args, "cycleId", "cycle_id", "id");
|
|
839
1291
|
if (!cycleId)
|
|
840
1292
|
throw new Error("cycleId is required for get_details action");
|
|
1293
|
+
validateId(cycleId, "cycleId");
|
|
841
1294
|
const data = await callApi("get", `projects/test-cycles/get-test-cycle-details/${projectId}/${cycleId}`);
|
|
1295
|
+
if (data?.data) {
|
|
1296
|
+
data.data.url = `${getFrontendBaseUrl()}/${projectId}/testcycles/${cycleId}`;
|
|
1297
|
+
}
|
|
842
1298
|
return jsonResponse(data);
|
|
843
1299
|
}
|
|
844
1300
|
case "get_testcases": {
|
|
845
1301
|
const cycleId = p(args, "cycleId", "cycle_id", "id");
|
|
846
1302
|
if (!cycleId)
|
|
847
1303
|
throw new Error("cycleId is required for get_testcases action");
|
|
1304
|
+
validateId(cycleId, "cycleId");
|
|
848
1305
|
const data = await callApi("get", `projects/test-cycles/testcases/${projectId}/${cycleId}`, {
|
|
849
1306
|
query: {
|
|
850
1307
|
search: p(args, "search"),
|
|
@@ -861,8 +1318,9 @@ async function handleManageTestCycle(args) {
|
|
|
861
1318
|
throw new Error("cycleId is required for link_testcases action");
|
|
862
1319
|
if (!tcIds)
|
|
863
1320
|
throw new Error("testcase_ids is required for link_testcases action");
|
|
864
|
-
|
|
865
|
-
const
|
|
1321
|
+
validateId(cycleId, "cycleId");
|
|
1322
|
+
const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
|
|
1323
|
+
const data = await callApi("post", `projects/test-cycles/testcases/link/${projectId}/${cycleId}`, { body: { ids: idsStr } });
|
|
866
1324
|
return jsonResponse(data);
|
|
867
1325
|
}
|
|
868
1326
|
case "unlink_testcases": {
|
|
@@ -872,8 +1330,9 @@ async function handleManageTestCycle(args) {
|
|
|
872
1330
|
throw new Error("cycleId is required for unlink_testcases action");
|
|
873
1331
|
if (!tcIds)
|
|
874
1332
|
throw new Error("testcase_ids is required for unlink_testcases action");
|
|
875
|
-
|
|
876
|
-
const
|
|
1333
|
+
validateId(cycleId, "cycleId");
|
|
1334
|
+
const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
|
|
1335
|
+
const data = await callApi("delete", `projects/test-cycles/testcases/unlink/${projectId}/${cycleId}`, { body: { ids: idsStr } });
|
|
877
1336
|
return jsonResponse(data);
|
|
878
1337
|
}
|
|
879
1338
|
case "assign_testcases": {
|
|
@@ -886,8 +1345,9 @@ async function handleManageTestCycle(args) {
|
|
|
886
1345
|
throw new Error("testcase_ids is required for assign_testcases action");
|
|
887
1346
|
if (!assigneeId)
|
|
888
1347
|
throw new Error("assignee_id is required for assign_testcases action");
|
|
889
|
-
|
|
890
|
-
const
|
|
1348
|
+
validateId(cycleId, "cycleId");
|
|
1349
|
+
const idsStr = Array.isArray(tcIds) ? tcIds.join(",") : String(tcIds);
|
|
1350
|
+
const data = await callApi("patch", `projects/test-cycles/testcases/assign/${projectId}/${cycleId}`, { body: { ids: idsStr, assignee: Number(assigneeId) } });
|
|
891
1351
|
return jsonResponse(data);
|
|
892
1352
|
}
|
|
893
1353
|
default:
|
|
@@ -905,37 +1365,60 @@ async function handleExecuteTests(args) {
|
|
|
905
1365
|
throw new Error("cycleId is required");
|
|
906
1366
|
if (!resultsRaw)
|
|
907
1367
|
throw new Error("results is required");
|
|
908
|
-
|
|
1368
|
+
validateId(projectId, "projectId");
|
|
1369
|
+
validateId(cycleId, "cycleId");
|
|
1370
|
+
const results = safeJsonParse(resultsRaw, "results");
|
|
909
1371
|
if (!Array.isArray(results) || results.length === 0) {
|
|
910
1372
|
throw new Error("results must be a non-empty array");
|
|
911
1373
|
}
|
|
912
|
-
|
|
913
|
-
if (
|
|
914
|
-
|
|
1374
|
+
const mode = p(args, "mode") || "manual";
|
|
1375
|
+
if (mode !== "manual" && mode !== "automation") {
|
|
1376
|
+
throw new Error("mode must be 'manual' or 'automation'");
|
|
1377
|
+
}
|
|
1378
|
+
// Validate all results before recording any
|
|
1379
|
+
const normalized = results.map((r, i) => {
|
|
915
1380
|
const testcaseId = r.testcase_id || r.testcaseId;
|
|
1381
|
+
const status = r.execution_status || r.executionStatus;
|
|
916
1382
|
if (!testcaseId)
|
|
917
|
-
throw new Error(
|
|
918
|
-
|
|
919
|
-
|
|
1383
|
+
throw new Error(`results[${i}]: testcase_id is required`);
|
|
1384
|
+
if (!status) {
|
|
1385
|
+
throw new Error(`results[${i}]: execution_status is required. Valid values: pass, fail, blocked, not_executed`);
|
|
1386
|
+
}
|
|
1387
|
+
const out = {
|
|
1388
|
+
testcase_id: validateId(testcaseId, "testcase_id"),
|
|
1389
|
+
execution_status: status,
|
|
920
1390
|
};
|
|
921
|
-
|
|
922
|
-
|
|
1391
|
+
const actual = r.actual_result || r.actualResult;
|
|
1392
|
+
if (actual)
|
|
1393
|
+
out.actual_result = actual;
|
|
923
1394
|
if (r.environment)
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
1395
|
+
out.environment = r.environment;
|
|
1396
|
+
return out;
|
|
1397
|
+
});
|
|
1398
|
+
if (mode === "automation") {
|
|
1399
|
+
const data = await callApi("post", `projects/test-cycles/execute-bulk/${projectId}/${cycleId}`, { body: { results: normalized } });
|
|
1400
|
+
const errors = data?.data?.errors || data?.errors;
|
|
1401
|
+
return jsonResponseWithHints(data, Array.isArray(errors) && errors.length
|
|
1402
|
+
? ["Some results were NOT recorded (see errors) — e.g. test cases not linked to this cycle."]
|
|
1403
|
+
: []);
|
|
927
1404
|
}
|
|
928
|
-
//
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1405
|
+
// Manual: same endpoint the app uses when a tester sets a result
|
|
1406
|
+
const recorded = [];
|
|
1407
|
+
const failed = [];
|
|
1408
|
+
for (const { testcase_id, ...body } of normalized) {
|
|
1409
|
+
try {
|
|
1410
|
+
await callApi("patch", `projects/test-cycles/testcases/update-execution/${projectId}/${cycleId}/${testcase_id}`, { body });
|
|
1411
|
+
recorded.push(testcase_id);
|
|
1412
|
+
}
|
|
1413
|
+
catch (error) {
|
|
1414
|
+
const err = axios.isAxiosError(error) ? handleApiError(error) : error;
|
|
1415
|
+
failed.push({ testcase_id, error: err.message });
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (recorded.length === 0) {
|
|
1419
|
+
throw new Error(`No results were recorded:\n${failed.map((f) => `${f.testcase_id}: ${f.error}`).join("\n")}`);
|
|
1420
|
+
}
|
|
1421
|
+
return jsonResponseWithHints({ status: failed.length ? "partial_success" : "success", recorded, failed }, failed.length ? ["Some results were NOT recorded (see failed)."] : []);
|
|
939
1422
|
}
|
|
940
1423
|
// 10. manage_test_plan
|
|
941
1424
|
async function handleManageTestPlan(args) {
|
|
@@ -945,6 +1428,7 @@ async function handleManageTestPlan(args) {
|
|
|
945
1428
|
throw new Error("projectId is required");
|
|
946
1429
|
if (!action)
|
|
947
1430
|
throw new Error("action is required");
|
|
1431
|
+
validateId(projectId, "projectId");
|
|
948
1432
|
switch (action) {
|
|
949
1433
|
case "create": {
|
|
950
1434
|
const title = p(args, "title");
|
|
@@ -960,7 +1444,7 @@ async function handleManageTestPlan(args) {
|
|
|
960
1444
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
961
1445
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
962
1446
|
if (p(args, "folder_id", "folderId"))
|
|
963
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1447
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
964
1448
|
const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
|
|
965
1449
|
return jsonResponse(data);
|
|
966
1450
|
}
|
|
@@ -980,7 +1464,7 @@ async function handleManageTestPlan(args) {
|
|
|
980
1464
|
if (p(args, "planned_end_date", "plannedEndDate"))
|
|
981
1465
|
body.planned_end_date = p(args, "planned_end_date", "plannedEndDate");
|
|
982
1466
|
if (p(args, "folder_id", "folderId"))
|
|
983
|
-
body.folder_id = p(args, "folder_id", "folderId");
|
|
1467
|
+
body.folder_id = Number(p(args, "folder_id", "folderId"));
|
|
984
1468
|
const data = await callApi("post", `projects/test-plans/create-test-plan/${projectId}`, { body });
|
|
985
1469
|
return jsonResponse(data);
|
|
986
1470
|
}
|
|
@@ -988,8 +1472,8 @@ async function handleManageTestPlan(args) {
|
|
|
988
1472
|
const ids = p(args, "ids", "planId", "plan_id");
|
|
989
1473
|
if (!ids)
|
|
990
1474
|
throw new Error("ids (or planId) is required for delete action");
|
|
991
|
-
const
|
|
992
|
-
const data = await callApi("delete", `projects/test-plans/delete-test-plan/${projectId}`, { body: { ids:
|
|
1475
|
+
const idsStr = Array.isArray(ids) ? ids.join(",") : String(ids);
|
|
1476
|
+
const data = await callApi("delete", `projects/test-plans/delete-test-plan/${projectId}`, { body: { ids: idsStr } });
|
|
993
1477
|
return jsonResponse(data);
|
|
994
1478
|
}
|
|
995
1479
|
case "list": {
|
|
@@ -1000,13 +1484,25 @@ async function handleManageTestPlan(args) {
|
|
|
1000
1484
|
limit: p(args, "limit"),
|
|
1001
1485
|
},
|
|
1002
1486
|
});
|
|
1487
|
+
const baseUrl = getFrontendBaseUrl();
|
|
1488
|
+
if (Array.isArray(data?.data)) {
|
|
1489
|
+
data.data.forEach((plan) => {
|
|
1490
|
+
const id = plan.test_plan_id || plan.id;
|
|
1491
|
+
if (id)
|
|
1492
|
+
plan.url = `${baseUrl}/${projectId}/testplans/${id}`;
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1003
1495
|
return jsonResponse(data);
|
|
1004
1496
|
}
|
|
1005
1497
|
case "get_details": {
|
|
1006
1498
|
const planId = p(args, "planId", "plan_id", "id");
|
|
1007
1499
|
if (!planId)
|
|
1008
1500
|
throw new Error("planId is required for get_details action");
|
|
1501
|
+
validateId(planId, "planId");
|
|
1009
1502
|
const data = await callApi("get", `projects/test-plans/get-test-plan-details/${projectId}/${planId}`);
|
|
1503
|
+
if (data?.data) {
|
|
1504
|
+
data.data.url = `${getFrontendBaseUrl()}/${projectId}/testplans/${planId}`;
|
|
1505
|
+
}
|
|
1010
1506
|
return jsonResponse(data);
|
|
1011
1507
|
}
|
|
1012
1508
|
case "link_cycles": {
|
|
@@ -1016,8 +1512,9 @@ async function handleManageTestPlan(args) {
|
|
|
1016
1512
|
throw new Error("planId is required for link_cycles action");
|
|
1017
1513
|
if (!cycleIds)
|
|
1018
1514
|
throw new Error("cycle_ids is required for link_cycles action");
|
|
1019
|
-
|
|
1020
|
-
const
|
|
1515
|
+
validateId(planId, "planId");
|
|
1516
|
+
const idsStr = Array.isArray(cycleIds) ? cycleIds.join(",") : String(cycleIds);
|
|
1517
|
+
const data = await callApi("post", `projects/test-plans/testcycles/link/${projectId}/${planId}`, { body: { ids: idsStr } });
|
|
1021
1518
|
return jsonResponse(data);
|
|
1022
1519
|
}
|
|
1023
1520
|
case "unlink_cycles": {
|
|
@@ -1027,14 +1524,16 @@ async function handleManageTestPlan(args) {
|
|
|
1027
1524
|
throw new Error("planId is required for unlink_cycles action");
|
|
1028
1525
|
if (!cycleIds)
|
|
1029
1526
|
throw new Error("cycle_ids is required for unlink_cycles action");
|
|
1030
|
-
|
|
1031
|
-
const
|
|
1527
|
+
validateId(planId, "planId");
|
|
1528
|
+
const idsStr = Array.isArray(cycleIds) ? cycleIds.join(",") : String(cycleIds);
|
|
1529
|
+
const data = await callApi("delete", `projects/test-plans/testcycles/unlink/${projectId}/${planId}`, { body: { ids: idsStr } });
|
|
1032
1530
|
return jsonResponse(data);
|
|
1033
1531
|
}
|
|
1034
1532
|
case "get_cycles": {
|
|
1035
1533
|
const planId = p(args, "planId", "plan_id", "id");
|
|
1036
1534
|
if (!planId)
|
|
1037
1535
|
throw new Error("planId is required for get_cycles action");
|
|
1536
|
+
validateId(planId, "planId");
|
|
1038
1537
|
const data = await callApi("get", `projects/test-plans/testcycles/list/${projectId}/${planId}`, {
|
|
1039
1538
|
query: {
|
|
1040
1539
|
search: p(args, "search"),
|
|
@@ -1048,6 +1547,7 @@ async function handleManageTestPlan(args) {
|
|
|
1048
1547
|
const planId = p(args, "planId", "plan_id", "id");
|
|
1049
1548
|
if (!planId)
|
|
1050
1549
|
throw new Error("planId is required for get_testcases action");
|
|
1550
|
+
validateId(planId, "planId");
|
|
1051
1551
|
const data = await callApi("get", `projects/test-plans/testcases/list/${projectId}/${planId}`, {
|
|
1052
1552
|
query: {
|
|
1053
1553
|
search: p(args, "search"),
|
|
@@ -1067,6 +1567,7 @@ async function handleGetReport(args) {
|
|
|
1067
1567
|
const reportType = p(args, "report_type", "reportType");
|
|
1068
1568
|
if (!projectId)
|
|
1069
1569
|
throw new Error("projectId is required");
|
|
1570
|
+
validateId(projectId, "projectId");
|
|
1070
1571
|
if (!reportType)
|
|
1071
1572
|
throw new Error("report_type is required");
|
|
1072
1573
|
const endpointPath = REPORT_TYPE_MAP[reportType];
|
|
@@ -1074,20 +1575,31 @@ async function handleGetReport(args) {
|
|
|
1074
1575
|
throw new Error(`Unknown report_type: ${reportType}. Valid types: ${Object.keys(REPORT_TYPE_MAP).join(", ")}`);
|
|
1075
1576
|
}
|
|
1076
1577
|
const query = {};
|
|
1077
|
-
// Common filters
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1578
|
+
// Common filters (the backend takes a single cycle / plan id)
|
|
1579
|
+
const singleId = (value, name) => {
|
|
1580
|
+
if (!value)
|
|
1581
|
+
return undefined;
|
|
1582
|
+
const list = toStringArray(value);
|
|
1583
|
+
if (list.length > 1) {
|
|
1584
|
+
throw new Error(`${name} takes one ID. Call get_report once per ID, or use cycle_comparison for several cycles.`);
|
|
1585
|
+
}
|
|
1586
|
+
return validateId(list[0], name);
|
|
1587
|
+
};
|
|
1588
|
+
query.testCycleId = singleId(p(args, "test_cycle_id", "test_cycle_ids", "testCycleId", "testCycleIds"), "test_cycle_id");
|
|
1589
|
+
query.testPlanId = singleId(p(args, "test_plan_id", "test_plan_ids", "testPlanId", "testPlanIds"), "test_plan_id");
|
|
1590
|
+
if (p(args, "folder_id", "folderId"))
|
|
1591
|
+
query.folderId = String(p(args, "folder_id", "folderId"));
|
|
1592
|
+
if (p(args, "startDate", "start_date", "dateFrom"))
|
|
1593
|
+
query.dateFrom = p(args, "startDate", "start_date", "dateFrom");
|
|
1594
|
+
const endDate = p(args, "endDate", "end_date", "dateTo");
|
|
1595
|
+
// The backend compares with <=, so a bare date would stop at midnight and drop the end day
|
|
1596
|
+
if (endDate)
|
|
1597
|
+
query.dateTo = /^\d{4}-\d{2}-\d{2}$/.test(endDate) ? `${endDate}T23:59:59.999Z` : endDate;
|
|
1086
1598
|
if (p(args, "granularity"))
|
|
1087
1599
|
query.granularity = p(args, "granularity");
|
|
1088
1600
|
// cycle_comparison specific
|
|
1089
1601
|
if (p(args, "cycle_ids", "cycleIds"))
|
|
1090
|
-
query.
|
|
1602
|
+
query.cycleIds = p(args, "cycle_ids", "cycleIds");
|
|
1091
1603
|
const data = await callApi("get", `projects/reports/${endpointPath}/${projectId}`, {
|
|
1092
1604
|
query,
|
|
1093
1605
|
});
|
|
@@ -1096,7 +1608,7 @@ async function handleGetReport(args) {
|
|
|
1096
1608
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1097
1609
|
// SERVER SETUP
|
|
1098
1610
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
1099
|
-
const server = new Server({ name: "testkase-mcp-server", version:
|
|
1611
|
+
const server = new Server({ name: "testkase-mcp-server", version: VERSION }, { capabilities: { tools: {} } });
|
|
1100
1612
|
// List all 11 tools
|
|
1101
1613
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1102
1614
|
tools: [
|
|
@@ -1145,9 +1657,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1145
1657
|
}
|
|
1146
1658
|
}
|
|
1147
1659
|
catch (error) {
|
|
1148
|
-
const err = error instanceof Error ? error :
|
|
1660
|
+
const err = axios.isAxiosError(error) ? handleApiError(error) : (error instanceof Error ? error : new Error(String(error)));
|
|
1661
|
+
const enrichedMessage = enrichErrorMessage(err.message);
|
|
1149
1662
|
return {
|
|
1150
|
-
content: [{ type: "text", text: `Error: ${
|
|
1663
|
+
content: [{ type: "text", text: `Error: ${enrichedMessage}` }],
|
|
1151
1664
|
isError: true,
|
|
1152
1665
|
};
|
|
1153
1666
|
}
|
|
@@ -1156,7 +1669,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1156
1669
|
async function main() {
|
|
1157
1670
|
const transport = new StdioServerTransport();
|
|
1158
1671
|
await server.connect(transport);
|
|
1159
|
-
console.error(
|
|
1672
|
+
console.error(`TestKase MCP Server v${VERSION} running on stdio (11 tools) → ${config.apiBaseUrl}`);
|
|
1160
1673
|
}
|
|
1161
1674
|
main().catch((error) => {
|
|
1162
1675
|
console.error("Fatal error in main():", error);
|