From e7cfff055705fdd5756b1e2a86a71df44df24f8c Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 8 Jul 2026 14:26:09 +0530 Subject: [PATCH 1/3] feat: add test cases to existing test run via updateTestRun --- .../testmanagement-utils/update-testrun.ts | 140 ++++++++++++- src/tools/testmanagement.ts | 2 +- tests/tools/update-testrun.test.ts | 187 ++++++++++++++++++ 3 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 tests/tools/update-testrun.test.ts diff --git a/src/tools/testmanagement-utils/update-testrun.ts b/src/tools/testmanagement-utils/update-testrun.ts index 6bee492..5dd6f14 100644 --- a/src/tools/testmanagement-utils/update-testrun.ts +++ b/src/tools/testmanagement-utils/update-testrun.ts @@ -6,6 +6,17 @@ import { formatAxiosError } from "../../lib/error.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { getTMBaseURL } from "../../lib/tm-base-url.js"; +/** + * Selection of test cases (with optional configurations) to add. + */ +const TestCaseSelectionSchema = z.object({ + test_case_ids: z.array(z.string()).describe("Test case IDs, e.g. TC-123"), + configuration_ids: z + .array(z.number()) + .optional() + .describe("Configuration IDs to apply"), +}); + /** * Schema for updating a test run with partial fields. */ @@ -27,6 +38,14 @@ export const UpdateTestRunSchema = z.object({ ]) .optional() .describe("Updated state of the test run"), + add_test_cases: z + .array(TestCaseSelectionSchema) + .optional() + .describe("Test cases to add to the run"), + preserve_existing_results: z + .boolean() + .optional() + .describe("Keep existing results when adding cases (default true)"), }), }); @@ -34,13 +53,67 @@ type UpdateTestRunArgs = z.infer; /** * Partially updates an existing test run. + * + * Dispatches to one of two BrowserStack endpoints based on the fields provided, + * mirroring how the platform splits these concerns across two endpoints: + * - metadata (name / run_state) -> PATCH .../test-runs/{id}/update + * - adding test cases -> PATCH .../test-runs/{id}/test-cases + * + * Either or both may be supplied in one call; each provided concern hits its + * own endpoint and both outcomes are reported. At least one must be provided. + * Removing test cases is intentionally not exposed — this tool is + * non-destructive. */ export async function updateTestRun( args: UpdateTestRunArgs, config: BrowserStackConfig, +): Promise { + const { name, run_state, add_test_cases } = args.test_run; + + const hasTestCases = (add_test_cases?.length ?? 0) > 0; + const hasMetadata = name !== undefined || run_state !== undefined; + + if (!hasTestCases && !hasMetadata) { + return { + content: [ + { + type: "text", + text: "Nothing to update: provide name/run_state and/or add_test_cases.", + }, + ], + isError: true, + }; + } + + const results: CallToolResult[] = []; + if (hasMetadata) { + results.push(await updateTestRunMetadata(args, config)); + } + if (hasTestCases) { + results.push(await updateTestRunTestCases(args, config)); + } + + if (results.length === 1) { + return results[0]; + } + + // Both concerns updated: aggregate outcomes; surface an error if either failed. + return { + content: results.flatMap((r) => r.content), + isError: results.some((r) => r.isError), + }; +} + +/** + * Updates test run metadata (name / run_state) via the /update endpoint. + */ +async function updateTestRunMetadata( + args: UpdateTestRunArgs, + config: BrowserStackConfig, ): Promise { try { - const body = { test_run: args.test_run }; + const { name, run_state } = args.test_run; + const body = { test_run: { name, run_state } }; const tmBaseUrl = await getTMBaseURL(config); const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent( args.project_identifier, @@ -85,3 +158,68 @@ export async function updateTestRun( return formatAxiosError(err, "Failed to update test run"); } } + +/** + * Adds test cases to a run via the /test-cases endpoint. + * This call is applied asynchronously by the backend. + */ +async function updateTestRunTestCases( + args: UpdateTestRunArgs, + config: BrowserStackConfig, +): Promise { + try { + const { add_test_cases, preserve_existing_results } = args.test_run; + + const body = { + test_run: { + add_test_cases, + preserve_existing_results: preserve_existing_results ?? true, + }, + }; + + const tmBaseUrl = await getTMBaseURL(config); + const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent( + args.project_identifier, + )}/test-runs/${encodeURIComponent(args.test_run_id)}/test-cases`; + + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + + const resp = await apiClient.patch({ + url, + headers: { + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + "Content-Type": "application/json", + }, + body, + }); + + const data = resp.data; + if (!data.success) { + return { + content: [ + { + type: "text", + text: `Failed to update test run test cases: ${JSON.stringify(data)}`, + }, + ], + isError: true, + }; + } + + const added = add_test_cases?.flatMap((s) => s.test_case_ids) ?? []; + + return { + content: [ + { + type: "text", + text: `Queued test-case update for ${args.test_run_id} (added ${added.length}); changes apply asynchronously.`, + }, + { type: "text", text: JSON.stringify(data, null, 2) }, + ], + }; + } catch (err) { + return formatAxiosError(err, "Failed to update test run test cases"); + } +} diff --git a/src/tools/testmanagement.ts b/src/tools/testmanagement.ts index 43e6f39..e664ca4 100644 --- a/src/tools/testmanagement.ts +++ b/src/tools/testmanagement.ts @@ -736,7 +736,7 @@ export default function addTestManagementTools( tools.updateTestRun = server.tool( "updateTestRun", - "Update a test run in BrowserStack Test Management.", + "Update a test run's metadata and/or add test cases to it.", UpdateTestRunSchema.shape, (args) => updateTestRunTool(args, config, server), ); diff --git a/tests/tools/update-testrun.test.ts b/tests/tools/update-testrun.test.ts new file mode 100644 index 0000000..a6b25f2 --- /dev/null +++ b/tests/tools/update-testrun.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/lib/apiClient", () => ({ + apiClient: { + patch: vi.fn(), + }, +})); + +vi.mock("../../src/lib/tm-base-url", () => ({ + getTMBaseURL: vi.fn().mockResolvedValue("https://test-management.browserstack.com"), +})); + +vi.mock("../../src/logger", () => ({ + default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})); + +import { apiClient } from "../../src/lib/apiClient"; +import { updateTestRun } from "../../src/tools/testmanagement-utils/update-testrun"; + +const mockConfig = { + "browserstack-username": "config-user", + "browserstack-access-key": "config-key", +} as any; + +describe("updateTestRun — endpoint dispatch", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("routes metadata (name/run_state) to the /update endpoint", async () => { + (apiClient.patch as any).mockResolvedValue({ + data: { success: true, testrun: { name: "New" } }, + }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-1", + test_run: { name: "New", run_state: "in_progress" }, + }, + mockConfig, + ); + + expect(result.isError).toBeFalsy(); + const call = (apiClient.patch as any).mock.calls[0][0]; + expect(call.url).toMatch(/\/test-runs\/TR-1\/update$/); + expect(call.body).toEqual({ + test_run: { name: "New", run_state: "in_progress" }, + }); + expect(result.content?.[0]?.text).toContain( + "Successfully updated test run TR-1", + ); + }); + + it("routes add_test_cases to the /test-cases endpoint with preserve default", async () => { + (apiClient.patch as any).mockResolvedValue({ + data: { success: true, async: true, unique_id: "abc" }, + }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-9", + test_run: { + add_test_cases: [{ test_case_ids: ["TC-1", "TC-2"] }], + }, + }, + mockConfig, + ); + + expect(result.isError).toBeFalsy(); + const call = (apiClient.patch as any).mock.calls[0][0]; + expect(call.url).toMatch(/\/test-runs\/TR-9\/test-cases$/); + expect(call.body.test_run.add_test_cases).toEqual([ + { test_case_ids: ["TC-1", "TC-2"] }, + ]); + expect(call.body.test_run.preserve_existing_results).toBe(true); + expect(result.content?.[0]?.text).toContain("added 2"); + }); + + it("honors the preserve_existing_results flag when adding", async () => { + (apiClient.patch as any).mockResolvedValue({ + data: { success: true, async: true, unique_id: "def" }, + }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-9", + test_run: { + add_test_cases: [{ test_case_ids: ["TC-3"] }], + preserve_existing_results: false, + }, + }, + mockConfig, + ); + + expect(result.isError).toBeFalsy(); + const call = (apiClient.patch as any).mock.calls[0][0]; + expect(call.url).toMatch(/\/test-runs\/TR-9\/test-cases$/); + expect(call.body.test_run.preserve_existing_results).toBe(false); + expect(result.content?.[0]?.text).toContain("added 1"); + }); + + it("updates both metadata and test cases when both are provided", async () => { + (apiClient.patch as any) + .mockResolvedValueOnce({ data: { success: true, testrun: { name: "New" } } }) + .mockResolvedValueOnce({ data: { success: true, async: true, unique_id: "z" } }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-1", + test_run: { + name: "New", + add_test_cases: [{ test_case_ids: ["TC-1"] }], + }, + }, + mockConfig, + ); + + expect(result.isError).toBeFalsy(); + expect(apiClient.patch).toHaveBeenCalledTimes(2); + const urls = (apiClient.patch as any).mock.calls.map((c: any) => c[0].url); + expect(urls.some((u: string) => /\/test-runs\/TR-1\/update$/.test(u))).toBe(true); + expect(urls.some((u: string) => /\/test-runs\/TR-1\/test-cases$/.test(u))).toBe(true); + const combined = (result.content ?? []).map((c: any) => c.text).join("\n"); + expect(combined).toContain("Successfully updated test run TR-1"); + expect(combined).toContain("added 1"); + }); + + it("reports an error if one of the two updates fails", async () => { + (apiClient.patch as any) + .mockResolvedValueOnce({ data: { success: true, testrun: {} } }) + .mockResolvedValueOnce({ data: { success: false, error: "closed_run" } }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-1", + test_run: { + run_state: "in_progress", + add_test_cases: [{ test_case_ids: ["TC-1"] }], + }, + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(apiClient.patch).toHaveBeenCalledTimes(2); + }); + + it("rejects an empty update with no actionable fields", async () => { + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-1", + test_run: {}, + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(apiClient.patch).not.toHaveBeenCalled(); + expect(result.content?.[0]?.text).toContain("Nothing to update"); + }); + + it("surfaces an unsuccessful membership response as an error", async () => { + (apiClient.patch as any).mockResolvedValue({ + data: { success: false, error: "closed_run" }, + }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-9", + test_run: { add_test_cases: [{ test_case_ids: ["TC-1"] }] }, + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(result.content?.[0]?.text).toContain( + "Failed to update test run test cases", + ); + }); +}); From f2680dfdd70a2e9a2d2b0203ca03da7ea432f5ce Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 8 Jul 2026 18:52:31 +0530 Subject: [PATCH 2/3] refactor(testmanagement): address updateTestRun PR review --- README.md | 2 +- .../testmanagement-utils/update-testrun.ts | 50 +++++++------ tests/tools/update-testrun.test.ts | 75 +++++++++++++++++++ 3 files changed, 105 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 7f615f6..23a1dce 100644 --- a/README.md +++ b/README.md @@ -334,7 +334,7 @@ As of now we support 20 tools. List all test runs from the 'Shopping App' project that were executed last week and are currently marked in-progress ``` - 6. `updateTestRun` — Partially update a test run (status, tags, notes, associated test cases). + 6. `updateTestRun` — Update a test run's name/state and/or add test cases to it. **Prompt example** ```text diff --git a/src/tools/testmanagement-utils/update-testrun.ts b/src/tools/testmanagement-utils/update-testrun.ts index 5dd6f14..02ca0b4 100644 --- a/src/tools/testmanagement-utils/update-testrun.ts +++ b/src/tools/testmanagement-utils/update-testrun.ts @@ -10,7 +10,10 @@ import { getTMBaseURL } from "../../lib/tm-base-url.js"; * Selection of test cases (with optional configurations) to add. */ const TestCaseSelectionSchema = z.object({ - test_case_ids: z.array(z.string()).describe("Test case IDs, e.g. TC-123"), + test_case_ids: z + .array(z.string()) + .min(1) + .describe("Test case IDs, e.g. TC-123"), configuration_ids: z .array(z.number()) .optional() @@ -51,6 +54,14 @@ export const UpdateTestRunSchema = z.object({ type UpdateTestRunArgs = z.infer; +/** + * Builds the HTTP Basic auth header from per-request config credentials. + */ +function buildAuthHeader(config: BrowserStackConfig): string { + const [username, password] = getBrowserStackAuth(config).split(":"); + return "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); +} + /** * Partially updates an existing test run. * @@ -70,7 +81,8 @@ export async function updateTestRun( ): Promise { const { name, run_state, add_test_cases } = args.test_run; - const hasTestCases = (add_test_cases?.length ?? 0) > 0; + const addIds = add_test_cases?.flatMap((s) => s.test_case_ids) ?? []; + const hasTestCases = addIds.length > 0; const hasMetadata = name !== undefined || run_state !== undefined; if (!hasTestCases && !hasMetadata) { @@ -85,14 +97,18 @@ export async function updateTestRun( }; } - const results: CallToolResult[] = []; + const tmBaseUrl = await getTMBaseURL(config); + const authHeader = buildAuthHeader(config); + + const tasks: Promise[] = []; if (hasMetadata) { - results.push(await updateTestRunMetadata(args, config)); + tasks.push(updateTestRunMetadata(args, tmBaseUrl, authHeader)); } if (hasTestCases) { - results.push(await updateTestRunTestCases(args, config)); + tasks.push(updateTestRunTestCases(args, tmBaseUrl, authHeader)); } + const results = await Promise.all(tasks); if (results.length === 1) { return results[0]; } @@ -109,24 +125,20 @@ export async function updateTestRun( */ async function updateTestRunMetadata( args: UpdateTestRunArgs, - config: BrowserStackConfig, + baseUrl: string, + authHeader: string, ): Promise { try { const { name, run_state } = args.test_run; const body = { test_run: { name, run_state } }; - const tmBaseUrl = await getTMBaseURL(config); - const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent( + const url = `${baseUrl}/api/v2/projects/${encodeURIComponent( args.project_identifier, )}/test-runs/${encodeURIComponent(args.test_run_id)}/update`; - const authString = getBrowserStackAuth(config); - const [username, password] = authString.split(":"); - const resp = await apiClient.patch({ url, headers: { - Authorization: - "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + Authorization: authHeader, "Content-Type": "application/json", }, body, @@ -165,7 +177,8 @@ async function updateTestRunMetadata( */ async function updateTestRunTestCases( args: UpdateTestRunArgs, - config: BrowserStackConfig, + baseUrl: string, + authHeader: string, ): Promise { try { const { add_test_cases, preserve_existing_results } = args.test_run; @@ -177,19 +190,14 @@ async function updateTestRunTestCases( }, }; - const tmBaseUrl = await getTMBaseURL(config); - const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent( + const url = `${baseUrl}/api/v2/projects/${encodeURIComponent( args.project_identifier, )}/test-runs/${encodeURIComponent(args.test_run_id)}/test-cases`; - const authString = getBrowserStackAuth(config); - const [username, password] = authString.split(":"); - const resp = await apiClient.patch({ url, headers: { - Authorization: - "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + Authorization: authHeader, "Content-Type": "application/json", }, body, diff --git a/tests/tools/update-testrun.test.ts b/tests/tools/update-testrun.test.ts index a6b25f2..f9e3946 100644 --- a/tests/tools/update-testrun.test.ts +++ b/tests/tools/update-testrun.test.ts @@ -184,4 +184,79 @@ describe("updateTestRun — endpoint dispatch", () => { "Failed to update test run test cases", ); }); + + it("forwards configuration_ids in the test-cases body", async () => { + (apiClient.patch as any).mockResolvedValue({ + data: { success: true, async: true, unique_id: "cfg" }, + }); + + await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-9", + test_run: { + add_test_cases: [ + { test_case_ids: ["TC-1"], configuration_ids: [3, 4] }, + ], + }, + }, + mockConfig, + ); + + const call = (apiClient.patch as any).mock.calls[0][0]; + expect(call.body.test_run.add_test_cases[0].configuration_ids).toEqual([ + 3, 4, + ]); + }); + + it("returns an error when the PATCH rejects (catch path)", async () => { + (apiClient.patch as any).mockRejectedValue(new Error("network down")); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-9", + test_run: { add_test_cases: [{ test_case_ids: ["TC-1"] }] }, + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + }); + + it("rejects an empty test_case_ids selection without any API call", async () => { + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-9", + test_run: { add_test_cases: [{ test_case_ids: [] }] }, + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(apiClient.patch).not.toHaveBeenCalled(); + expect(result.content?.[0]?.text).toContain("Nothing to update"); + }); + + it("flags an error when both concerns fail", async () => { + (apiClient.patch as any) + .mockResolvedValueOnce({ data: { success: false } }) + .mockResolvedValueOnce({ data: { success: false } }); + + const result = await updateTestRun( + { + project_identifier: "PR-1", + test_run_id: "TR-1", + test_run: { + run_state: "in_progress", + add_test_cases: [{ test_case_ids: ["TC-1"] }], + }, + }, + mockConfig, + ); + + expect(result.isError).toBe(true); + expect(apiClient.patch).toHaveBeenCalledTimes(2); + }); }); From ec4310889b059adccdd1fcf49507af5099fa84dd Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Thu, 9 Jul 2026 13:04:22 +0530 Subject: [PATCH 3/3] refactor: simplify buildAuthHeader --- src/tools/testmanagement-utils/update-testrun.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tools/testmanagement-utils/update-testrun.ts b/src/tools/testmanagement-utils/update-testrun.ts index 02ca0b4..ef77db3 100644 --- a/src/tools/testmanagement-utils/update-testrun.ts +++ b/src/tools/testmanagement-utils/update-testrun.ts @@ -58,8 +58,7 @@ type UpdateTestRunArgs = z.infer; * Builds the HTTP Basic auth header from per-request config credentials. */ function buildAuthHeader(config: BrowserStackConfig): string { - const [username, password] = getBrowserStackAuth(config).split(":"); - return "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); + return "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); } /**