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 6bee492..ef77db3 100644 --- a/src/tools/testmanagement-utils/update-testrun.ts +++ b/src/tools/testmanagement-utils/update-testrun.ts @@ -6,6 +6,20 @@ 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()) + .min(1) + .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,33 +41,103 @@ 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)"), }), }); type UpdateTestRunArgs = z.infer; +/** + * Builds the HTTP Basic auth header from per-request config credentials. + */ +function buildAuthHeader(config: BrowserStackConfig): string { + return "Basic " + Buffer.from(getBrowserStackAuth(config)).toString("base64"); +} + /** * 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 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) { + return { + content: [ + { + type: "text", + text: "Nothing to update: provide name/run_state and/or add_test_cases.", + }, + ], + isError: true, + }; + } + + const tmBaseUrl = await getTMBaseURL(config); + const authHeader = buildAuthHeader(config); + + const tasks: Promise[] = []; + if (hasMetadata) { + tasks.push(updateTestRunMetadata(args, tmBaseUrl, authHeader)); + } + if (hasTestCases) { + tasks.push(updateTestRunTestCases(args, tmBaseUrl, authHeader)); + } + + const results = await Promise.all(tasks); + 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, + baseUrl: string, + authHeader: string, ): Promise { try { - const body = { test_run: args.test_run }; - const tmBaseUrl = await getTMBaseURL(config); - const url = `${tmBaseUrl}/api/v2/projects/${encodeURIComponent( + const { name, run_state } = args.test_run; + const body = { test_run: { name, run_state } }; + 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, @@ -85,3 +169,64 @@ 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, + baseUrl: string, + authHeader: string, +): 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 url = `${baseUrl}/api/v2/projects/${encodeURIComponent( + args.project_identifier, + )}/test-runs/${encodeURIComponent(args.test_run_id)}/test-cases`; + + const resp = await apiClient.patch({ + url, + headers: { + Authorization: authHeader, + "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..f9e3946 --- /dev/null +++ b/tests/tools/update-testrun.test.ts @@ -0,0 +1,262 @@ +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", + ); + }); + + 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); + }); +});