Skip to content

Commit e7cfff0

Browse files
committed
feat: add test cases to existing test run via updateTestRun
1 parent 1a6c9fb commit e7cfff0

3 files changed

Lines changed: 327 additions & 2 deletions

File tree

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

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,17 @@ import { formatAxiosError } from "../../lib/error.js";
66
import { BrowserStackConfig } from "../../lib/types.js";
77
import { getTMBaseURL } from "../../lib/tm-base-url.js";
88

9+
/**
10+
* Selection of test cases (with optional configurations) to add.
11+
*/
12+
const TestCaseSelectionSchema = z.object({
13+
test_case_ids: z.array(z.string()).describe("Test case IDs, e.g. TC-123"),
14+
configuration_ids: z
15+
.array(z.number())
16+
.optional()
17+
.describe("Configuration IDs to apply"),
18+
});
19+
920
/**
1021
* Schema for updating a test run with partial fields.
1122
*/
@@ -27,20 +38,82 @@ export const UpdateTestRunSchema = z.object({
2738
])
2839
.optional()
2940
.describe("Updated state of the test run"),
41+
add_test_cases: z
42+
.array(TestCaseSelectionSchema)
43+
.optional()
44+
.describe("Test cases to add to the run"),
45+
preserve_existing_results: z
46+
.boolean()
47+
.optional()
48+
.describe("Keep existing results when adding cases (default true)"),
3049
}),
3150
});
3251

3352
type UpdateTestRunArgs = z.infer<typeof UpdateTestRunSchema>;
3453

3554
/**
3655
* Partially updates an existing test run.
56+
*
57+
* Dispatches to one of two BrowserStack endpoints based on the fields provided,
58+
* mirroring how the platform splits these concerns across two endpoints:
59+
* - metadata (name / run_state) -> PATCH .../test-runs/{id}/update
60+
* - adding test cases -> PATCH .../test-runs/{id}/test-cases
61+
*
62+
* Either or both may be supplied in one call; each provided concern hits its
63+
* own endpoint and both outcomes are reported. At least one must be provided.
64+
* Removing test cases is intentionally not exposed — this tool is
65+
* non-destructive.
3766
*/
3867
export async function updateTestRun(
3968
args: UpdateTestRunArgs,
4069
config: BrowserStackConfig,
70+
): Promise<CallToolResult> {
71+
const { name, run_state, add_test_cases } = args.test_run;
72+
73+
const hasTestCases = (add_test_cases?.length ?? 0) > 0;
74+
const hasMetadata = name !== undefined || run_state !== undefined;
75+
76+
if (!hasTestCases && !hasMetadata) {
77+
return {
78+
content: [
79+
{
80+
type: "text",
81+
text: "Nothing to update: provide name/run_state and/or add_test_cases.",
82+
},
83+
],
84+
isError: true,
85+
};
86+
}
87+
88+
const results: CallToolResult[] = [];
89+
if (hasMetadata) {
90+
results.push(await updateTestRunMetadata(args, config));
91+
}
92+
if (hasTestCases) {
93+
results.push(await updateTestRunTestCases(args, config));
94+
}
95+
96+
if (results.length === 1) {
97+
return results[0];
98+
}
99+
100+
// Both concerns updated: aggregate outcomes; surface an error if either failed.
101+
return {
102+
content: results.flatMap((r) => r.content),
103+
isError: results.some((r) => r.isError),
104+
};
105+
}
106+
107+
/**
108+
* Updates test run metadata (name / run_state) via the /update endpoint.
109+
*/
110+
async function updateTestRunMetadata(
111+
args: UpdateTestRunArgs,
112+
config: BrowserStackConfig,
41113
): Promise<CallToolResult> {
42114
try {
43-
const body = { test_run: args.test_run };
115+
const { name, run_state } = args.test_run;
116+
const body = { test_run: { name, run_state } };
44117
const tmBaseUrl = await getTMBaseURL(config);
45118
const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(
46119
args.project_identifier,
@@ -85,3 +158,68 @@ export async function updateTestRun(
85158
return formatAxiosError(err, "Failed to update test run");
86159
}
87160
}
161+
162+
/**
163+
* Adds test cases to a run via the /test-cases endpoint.
164+
* This call is applied asynchronously by the backend.
165+
*/
166+
async function updateTestRunTestCases(
167+
args: UpdateTestRunArgs,
168+
config: BrowserStackConfig,
169+
): Promise<CallToolResult> {
170+
try {
171+
const { add_test_cases, preserve_existing_results } = args.test_run;
172+
173+
const body = {
174+
test_run: {
175+
add_test_cases,
176+
preserve_existing_results: preserve_existing_results ?? true,
177+
},
178+
};
179+
180+
const tmBaseUrl = await getTMBaseURL(config);
181+
const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(
182+
args.project_identifier,
183+
)}/test-runs/${encodeURIComponent(args.test_run_id)}/test-cases`;
184+
185+
const authString = getBrowserStackAuth(config);
186+
const [username, password] = authString.split(":");
187+
188+
const resp = await apiClient.patch({
189+
url,
190+
headers: {
191+
Authorization:
192+
"Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
193+
"Content-Type": "application/json",
194+
},
195+
body,
196+
});
197+
198+
const data = resp.data;
199+
if (!data.success) {
200+
return {
201+
content: [
202+
{
203+
type: "text",
204+
text: `Failed to update test run test cases: ${JSON.stringify(data)}`,
205+
},
206+
],
207+
isError: true,
208+
};
209+
}
210+
211+
const added = add_test_cases?.flatMap((s) => s.test_case_ids) ?? [];
212+
213+
return {
214+
content: [
215+
{
216+
type: "text",
217+
text: `Queued test-case update for ${args.test_run_id} (added ${added.length}); changes apply asynchronously.`,
218+
},
219+
{ type: "text", text: JSON.stringify(data, null, 2) },
220+
],
221+
};
222+
} catch (err) {
223+
return formatAxiosError(err, "Failed to update test run test cases");
224+
}
225+
}

src/tools/testmanagement.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -736,7 +736,7 @@ export default function addTestManagementTools(
736736

737737
tools.updateTestRun = server.tool(
738738
"updateTestRun",
739-
"Update a test run in BrowserStack Test Management.",
739+
"Update a test run's metadata and/or add test cases to it.",
740740
UpdateTestRunSchema.shape,
741741
(args) => updateTestRunTool(args, config, server),
742742
);

tests/tools/update-testrun.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
vi.mock("../../src/lib/apiClient", () => ({
4+
apiClient: {
5+
patch: vi.fn(),
6+
},
7+
}));
8+
9+
vi.mock("../../src/lib/tm-base-url", () => ({
10+
getTMBaseURL: vi.fn().mockResolvedValue("https://test-management.browserstack.com"),
11+
}));
12+
13+
vi.mock("../../src/logger", () => ({
14+
default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() },
15+
}));
16+
17+
import { apiClient } from "../../src/lib/apiClient";
18+
import { updateTestRun } from "../../src/tools/testmanagement-utils/update-testrun";
19+
20+
const mockConfig = {
21+
"browserstack-username": "config-user",
22+
"browserstack-access-key": "config-key",
23+
} as any;
24+
25+
describe("updateTestRun — endpoint dispatch", () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
it("routes metadata (name/run_state) to the /update endpoint", async () => {
31+
(apiClient.patch as any).mockResolvedValue({
32+
data: { success: true, testrun: { name: "New" } },
33+
});
34+
35+
const result = await updateTestRun(
36+
{
37+
project_identifier: "PR-1",
38+
test_run_id: "TR-1",
39+
test_run: { name: "New", run_state: "in_progress" },
40+
},
41+
mockConfig,
42+
);
43+
44+
expect(result.isError).toBeFalsy();
45+
const call = (apiClient.patch as any).mock.calls[0][0];
46+
expect(call.url).toMatch(/\/test-runs\/TR-1\/update$/);
47+
expect(call.body).toEqual({
48+
test_run: { name: "New", run_state: "in_progress" },
49+
});
50+
expect(result.content?.[0]?.text).toContain(
51+
"Successfully updated test run TR-1",
52+
);
53+
});
54+
55+
it("routes add_test_cases to the /test-cases endpoint with preserve default", async () => {
56+
(apiClient.patch as any).mockResolvedValue({
57+
data: { success: true, async: true, unique_id: "abc" },
58+
});
59+
60+
const result = await updateTestRun(
61+
{
62+
project_identifier: "PR-1",
63+
test_run_id: "TR-9",
64+
test_run: {
65+
add_test_cases: [{ test_case_ids: ["TC-1", "TC-2"] }],
66+
},
67+
},
68+
mockConfig,
69+
);
70+
71+
expect(result.isError).toBeFalsy();
72+
const call = (apiClient.patch as any).mock.calls[0][0];
73+
expect(call.url).toMatch(/\/test-runs\/TR-9\/test-cases$/);
74+
expect(call.body.test_run.add_test_cases).toEqual([
75+
{ test_case_ids: ["TC-1", "TC-2"] },
76+
]);
77+
expect(call.body.test_run.preserve_existing_results).toBe(true);
78+
expect(result.content?.[0]?.text).toContain("added 2");
79+
});
80+
81+
it("honors the preserve_existing_results flag when adding", async () => {
82+
(apiClient.patch as any).mockResolvedValue({
83+
data: { success: true, async: true, unique_id: "def" },
84+
});
85+
86+
const result = await updateTestRun(
87+
{
88+
project_identifier: "PR-1",
89+
test_run_id: "TR-9",
90+
test_run: {
91+
add_test_cases: [{ test_case_ids: ["TC-3"] }],
92+
preserve_existing_results: false,
93+
},
94+
},
95+
mockConfig,
96+
);
97+
98+
expect(result.isError).toBeFalsy();
99+
const call = (apiClient.patch as any).mock.calls[0][0];
100+
expect(call.url).toMatch(/\/test-runs\/TR-9\/test-cases$/);
101+
expect(call.body.test_run.preserve_existing_results).toBe(false);
102+
expect(result.content?.[0]?.text).toContain("added 1");
103+
});
104+
105+
it("updates both metadata and test cases when both are provided", async () => {
106+
(apiClient.patch as any)
107+
.mockResolvedValueOnce({ data: { success: true, testrun: { name: "New" } } })
108+
.mockResolvedValueOnce({ data: { success: true, async: true, unique_id: "z" } });
109+
110+
const result = await updateTestRun(
111+
{
112+
project_identifier: "PR-1",
113+
test_run_id: "TR-1",
114+
test_run: {
115+
name: "New",
116+
add_test_cases: [{ test_case_ids: ["TC-1"] }],
117+
},
118+
},
119+
mockConfig,
120+
);
121+
122+
expect(result.isError).toBeFalsy();
123+
expect(apiClient.patch).toHaveBeenCalledTimes(2);
124+
const urls = (apiClient.patch as any).mock.calls.map((c: any) => c[0].url);
125+
expect(urls.some((u: string) => /\/test-runs\/TR-1\/update$/.test(u))).toBe(true);
126+
expect(urls.some((u: string) => /\/test-runs\/TR-1\/test-cases$/.test(u))).toBe(true);
127+
const combined = (result.content ?? []).map((c: any) => c.text).join("\n");
128+
expect(combined).toContain("Successfully updated test run TR-1");
129+
expect(combined).toContain("added 1");
130+
});
131+
132+
it("reports an error if one of the two updates fails", async () => {
133+
(apiClient.patch as any)
134+
.mockResolvedValueOnce({ data: { success: true, testrun: {} } })
135+
.mockResolvedValueOnce({ data: { success: false, error: "closed_run" } });
136+
137+
const result = await updateTestRun(
138+
{
139+
project_identifier: "PR-1",
140+
test_run_id: "TR-1",
141+
test_run: {
142+
run_state: "in_progress",
143+
add_test_cases: [{ test_case_ids: ["TC-1"] }],
144+
},
145+
},
146+
mockConfig,
147+
);
148+
149+
expect(result.isError).toBe(true);
150+
expect(apiClient.patch).toHaveBeenCalledTimes(2);
151+
});
152+
153+
it("rejects an empty update with no actionable fields", async () => {
154+
const result = await updateTestRun(
155+
{
156+
project_identifier: "PR-1",
157+
test_run_id: "TR-1",
158+
test_run: {},
159+
},
160+
mockConfig,
161+
);
162+
163+
expect(result.isError).toBe(true);
164+
expect(apiClient.patch).not.toHaveBeenCalled();
165+
expect(result.content?.[0]?.text).toContain("Nothing to update");
166+
});
167+
168+
it("surfaces an unsuccessful membership response as an error", async () => {
169+
(apiClient.patch as any).mockResolvedValue({
170+
data: { success: false, error: "closed_run" },
171+
});
172+
173+
const result = await updateTestRun(
174+
{
175+
project_identifier: "PR-1",
176+
test_run_id: "TR-9",
177+
test_run: { add_test_cases: [{ test_case_ids: ["TC-1"] }] },
178+
},
179+
mockConfig,
180+
);
181+
182+
expect(result.isError).toBe(true);
183+
expect(result.content?.[0]?.text).toContain(
184+
"Failed to update test run test cases",
185+
);
186+
});
187+
});

0 commit comments

Comments
 (0)