Skip to content

Commit 9c6ae0b

Browse files
committed
feat(test-management): add listFolders tool and extend updateTestCase fields
1 parent a54179e commit 9c6ae0b

4 files changed

Lines changed: 356 additions & 18 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { apiClient } from "../../lib/apiClient.js";
2+
import { z } from "zod";
3+
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
4+
import { formatAxiosError } from "../../lib/error.js";
5+
import { getBrowserStackAuth } from "../../lib/get-auth.js";
6+
import { BrowserStackConfig } from "../../lib/types.js";
7+
import { getTMBaseURL } from "../../lib/tm-base-url.js";
8+
9+
/**
10+
* Schema for listing folders in a BrowserStack Test Management project.
11+
*/
12+
export const ListFoldersSchema = z.object({
13+
project_identifier: z
14+
.string()
15+
.describe(
16+
"Identifier of the project to fetch folders from (starts with PR- followed by a number).",
17+
),
18+
parent_id: z
19+
.number()
20+
.optional()
21+
.describe(
22+
"If provided, list sub-folders under this parent folder id. If omitted, lists top-level folders.",
23+
),
24+
p: z.number().optional().describe("Page number."),
25+
});
26+
27+
export type ListFoldersArgs = z.infer<typeof ListFoldersSchema>;
28+
29+
interface FolderResponse {
30+
id: number;
31+
name: string;
32+
description: string | null;
33+
parent_id: number | null;
34+
cases_count: number;
35+
sub_folders_count: number;
36+
}
37+
38+
/**
39+
* Lists folders (or sub-folders) for a project in BrowserStack Test Management.
40+
*/
41+
export async function listFolders(
42+
args: ListFoldersArgs,
43+
config: BrowserStackConfig,
44+
): Promise<CallToolResult> {
45+
try {
46+
const params = new URLSearchParams();
47+
if (args.p !== undefined) params.append("p", args.p.toString());
48+
49+
const tmBaseUrl = await getTMBaseURL(config);
50+
const projectId = encodeURIComponent(args.project_identifier);
51+
52+
// GET /api/v2/projects/{projectIdentifier}/folders
53+
// or /api/v2/projects/{projectIdentifier}/folders/{parent_id}/sub-folders
54+
const path =
55+
args.parent_id !== undefined
56+
? `folders/${args.parent_id}/sub-folders`
57+
: `folders`;
58+
const url = `${tmBaseUrl}/api/v2/projects/${projectId}/${path}?${params.toString()}`;
59+
60+
const authString = getBrowserStackAuth(config);
61+
const [username, password] = authString.split(":");
62+
const resp = await apiClient.get({
63+
url,
64+
headers: {
65+
Authorization:
66+
"Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
67+
},
68+
});
69+
70+
const folders: FolderResponse[] = resp.data?.folders ?? [];
71+
const info = resp.data?.info ?? {};
72+
const count = info.count ?? folders.length;
73+
74+
if (folders.length === 0) {
75+
return {
76+
content: [
77+
{
78+
type: "text",
79+
text:
80+
args.parent_id !== undefined
81+
? `No sub-folders found under folder ${args.parent_id} in project ${args.project_identifier}.`
82+
: `No folders found in project ${args.project_identifier}.`,
83+
},
84+
],
85+
};
86+
}
87+
88+
const summary = folders
89+
.map(
90+
(f) =>
91+
`• [id=${f.id}] ${f.name}${f.cases_count} case(s), ${f.sub_folders_count} sub-folder(s)${f.parent_id ? ` (parent=${f.parent_id})` : ""}`,
92+
)
93+
.join("\n");
94+
95+
return {
96+
content: [
97+
{
98+
type: "text",
99+
text: `Found ${count} folder(s) in project ${args.project_identifier}:\n\n${summary}`,
100+
},
101+
{
102+
type: "text",
103+
text: JSON.stringify(folders, null, 2),
104+
},
105+
],
106+
};
107+
} catch (err) {
108+
return formatAxiosError(err, "Failed to list folders");
109+
}
110+
}

src/tools/testmanagement-utils/list-testcases.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ export const ListTestCasesSchema = z.object({
1818
folder_id: z
1919
.string()
2020
.optional()
21-
.describe("If provided, only return cases in this folder."),
21+
.describe(
22+
"Optional. If provided, only return test cases in this folder. If omitted, returns all test cases in the project. Folder ids can be discovered via listFolders.",
23+
),
2224
case_type: z
2325
.string()
2426
.optional()

src/tools/testmanagement-utils/update-testcase.ts

Lines changed: 197 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { apiClient } from "../../lib/apiClient.js";
22
import { z } from "zod";
33
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
44
import { formatAxiosError } from "../../lib/error.js";
5-
import { projectIdentifierToId } from "./TCG-utils/api.js";
5+
import { fetchFormFields, projectIdentifierToId } from "./TCG-utils/api.js";
66
import { BrowserStackConfig } from "../../lib/types.js";
77
import { getTMBaseURL } from "../../lib/tm-base-url.js";
88
import { getBrowserStackAuth } from "../../lib/get-auth.js";
@@ -18,6 +18,14 @@ export interface TestCaseUpdateRequest {
1818
step: string;
1919
result: string;
2020
}>;
21+
owner?: string;
22+
priority?: string;
23+
case_type?: string;
24+
automation_status?: string;
25+
status?: string;
26+
tags?: string[];
27+
issues?: string[];
28+
custom_fields?: Record<string, string | number | boolean>;
2129
}
2230

2331
export const UpdateTestCaseSchema = z.object({
@@ -49,8 +57,163 @@ export const UpdateTestCaseSchema = z.object({
4957
)
5058
.optional()
5159
.describe("Updated list of test case steps with expected results."),
60+
owner: z
61+
.string()
62+
.email()
63+
.optional()
64+
.describe("Email of the test case owner."),
65+
priority: z
66+
.string()
67+
.optional()
68+
.describe(
69+
"Updated priority. Accepts either display name (e.g. 'Medium', 'Critical', 'High', 'Low') or internal name (e.g. 'medium'). Valid values are per-project and discoverable via the form-fields endpoint.",
70+
),
71+
case_type: z
72+
.string()
73+
.optional()
74+
.describe(
75+
"Updated test case type. Accepts either display name (e.g. 'Functional', 'Regression', 'Smoke & Sanity') or internal name (e.g. 'functional', 'smoke_sanity'). Valid values are per-project.",
76+
),
77+
automation_status: z
78+
.string()
79+
.optional()
80+
.describe(
81+
"Updated automation status. Use internal name such as 'not_automated', 'automated', 'automation_not_required', 'cannot_be_automated', or 'obsolete'.",
82+
),
83+
status: z
84+
.string()
85+
.optional()
86+
.describe(
87+
"Updated review status of the test case (e.g. 'active', 'draft', 'in_review', 'outdated', 'rejected').",
88+
),
89+
tags: z
90+
.array(z.string())
91+
.optional()
92+
.describe("Replacement list of tags for the test case."),
93+
issues: z
94+
.array(z.string())
95+
.optional()
96+
.describe(
97+
"Replacement list of linked Jira/Asana/Azure issue IDs for the test case.",
98+
),
99+
custom_fields: z
100+
.record(
101+
z.string(),
102+
z.union([z.string(), z.number(), z.boolean()]),
103+
)
104+
.optional()
105+
.describe(
106+
"Map of custom field name/id to value. Valid field names and value types are per-project; discover them via the project's form fields.",
107+
),
52108
});
53109

110+
/**
111+
* Build a normalizer for a default field's accepted value.
112+
* The TM PATCH endpoint accepts different casings for different default
113+
* fields (Title-Case display name for priority/case_type, snake_case
114+
* internal_name for automation_status). We accept either from the caller
115+
* and emit the form the API actually wants.
116+
*
117+
* Returns undefined when no matching option is found — callers should
118+
* pass the raw value through so the backend can surface its own error.
119+
*/
120+
function normalizeDefaultFieldValue(
121+
fieldValues: Array<{ internal_name?: string | null; name?: string; value: any }>,
122+
input: string,
123+
emit: "name" | "internal_name",
124+
): string | undefined {
125+
const normalized = input.toLowerCase().trim();
126+
const match = fieldValues.find(
127+
(v) =>
128+
(v.internal_name ?? "").toLowerCase() === normalized ||
129+
(v.name ?? "").toLowerCase() === normalized,
130+
);
131+
if (!match) return undefined;
132+
if (emit === "name") return match.name;
133+
return match.internal_name ?? match.name;
134+
}
135+
136+
/**
137+
* Normalise default-field inputs (priority/case_type/automation_status) to
138+
* what the TM PATCH endpoint accepts. Fetches the project's form-fields
139+
* on demand; on failure, returns inputs unchanged and lets the backend
140+
* surface validation errors.
141+
*/
142+
async function normalizeDefaultFields(
143+
params: TestCaseUpdateRequest,
144+
config: BrowserStackConfig,
145+
): Promise<{
146+
priority?: string;
147+
case_type?: string;
148+
automation_status?: string;
149+
}> {
150+
const needsLookup =
151+
params.priority !== undefined ||
152+
params.case_type !== undefined ||
153+
params.automation_status !== undefined;
154+
if (!needsLookup) return {};
155+
156+
try {
157+
const numericProjectId = await projectIdentifierToId(
158+
params.project_identifier,
159+
config,
160+
);
161+
const { default_fields } = await fetchFormFields(numericProjectId, config);
162+
163+
const out: {
164+
priority?: string;
165+
case_type?: string;
166+
automation_status?: string;
167+
} = {};
168+
169+
if (params.priority !== undefined) {
170+
out.priority =
171+
normalizeDefaultFieldValue(
172+
default_fields?.priority?.values ?? [],
173+
params.priority,
174+
"name",
175+
) ?? params.priority;
176+
}
177+
if (params.case_type !== undefined) {
178+
out.case_type =
179+
normalizeDefaultFieldValue(
180+
default_fields?.case_type?.values ?? [],
181+
params.case_type,
182+
"name",
183+
) ?? params.case_type;
184+
}
185+
if (params.automation_status !== undefined) {
186+
// automation_status.values have null internal_name and the internal
187+
// name is actually held in `value` (see API inspection). Accept
188+
// either the display name or the internal snake_case form.
189+
const values =
190+
(default_fields?.automation_status?.values as Array<{
191+
name?: string;
192+
value?: string;
193+
}>) ?? [];
194+
const input = params.automation_status.toLowerCase().trim();
195+
const match = values.find(
196+
(v) =>
197+
(v.value ?? "").toLowerCase() === input ||
198+
(v.name ?? "").toLowerCase() === input,
199+
);
200+
out.automation_status = match?.value ?? params.automation_status;
201+
}
202+
203+
return out;
204+
} catch (err) {
205+
logger.warn(
206+
"Failed to normalize default field values; passing through as given: %s",
207+
err instanceof Error ? err.message : String(err),
208+
);
209+
return {
210+
priority: params.priority,
211+
case_type: params.case_type,
212+
automation_status: params.automation_status,
213+
};
214+
}
215+
}
216+
54217
/**
55218
* Updates an existing test case in BrowserStack Test Management.
56219
*/
@@ -61,23 +224,41 @@ export async function updateTestCase(
61224
const authString = getBrowserStackAuth(config);
62225
const [username, password] = authString.split(":");
63226

64-
// Build the request body with only the fields to update
65-
const testCaseBody: any = {};
227+
const testCaseBody: Record<string, any> = {};
66228

67-
if (params.name !== undefined) {
68-
testCaseBody.name = params.name;
69-
}
70-
71-
if (params.description !== undefined) {
229+
if (params.name !== undefined) testCaseBody.name = params.name;
230+
if (params.description !== undefined)
72231
testCaseBody.description = params.description;
73-
}
74-
75-
if (params.preconditions !== undefined) {
232+
if (params.preconditions !== undefined)
76233
testCaseBody.preconditions = params.preconditions;
77-
}
78-
79-
if (params.test_case_steps !== undefined) {
234+
if (params.test_case_steps !== undefined)
80235
testCaseBody.steps = params.test_case_steps;
236+
if (params.owner !== undefined) testCaseBody.owner = params.owner;
237+
if (params.status !== undefined) testCaseBody.status = params.status;
238+
if (params.tags !== undefined) testCaseBody.tags = params.tags;
239+
if (params.issues !== undefined) testCaseBody.issues = params.issues;
240+
if (params.custom_fields !== undefined)
241+
testCaseBody.custom_fields = params.custom_fields;
242+
243+
// Default fields need value normalization (see notes above the helper).
244+
const normalized = await normalizeDefaultFields(params, config);
245+
if (normalized.priority !== undefined)
246+
testCaseBody.priority = normalized.priority;
247+
if (normalized.case_type !== undefined)
248+
testCaseBody.case_type = normalized.case_type;
249+
if (normalized.automation_status !== undefined)
250+
testCaseBody.automation_status = normalized.automation_status;
251+
252+
if (Object.keys(testCaseBody).length === 0) {
253+
return {
254+
content: [
255+
{
256+
type: "text",
257+
text: "No updatable fields provided. Pass at least one of: name, description, preconditions, test_case_steps, owner, priority, case_type, automation_status, status, tags, issues, custom_fields.",
258+
},
259+
],
260+
isError: true,
261+
};
81262
}
82263

83264
const body = { test_case: testCaseBody };
@@ -124,14 +305,15 @@ export async function updateTestCase(
124305
{
125306
type: "text",
126307
text: `Test case successfully updated:
127-
308+
128309
**Test Case Details:**
129310
- **ID**: ${tc.identifier}
130311
- **Name**: ${tc.title}
131312
- **Description**: ${tc.description || "N/A"}
132313
- **Case Type**: ${tc.case_type}
133314
- **Priority**: ${tc.priority}
134315
- **Status**: ${tc.status}
316+
- **Automation Status**: ${tc.automation_status ?? "N/A"}
135317
136318
**View on BrowserStack Dashboard:**
137319
https://test-management.browserstack.com/projects/${projectId}/folders/${tc.folder_id}/test-cases/${tc.identifier}

0 commit comments

Comments
 (0)