From 006d7ec01794969c07f6f73c67cec5477db1339d Mon Sep 17 00:00:00 2001 From: manoj-k04 Date: Fri, 24 Apr 2026 19:58:01 +0530 Subject: [PATCH] feat(test-management): add listTestPlans and getTestPlan tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose Test Plans as first-class objects in the MCP so agents can retrieve a plan by TP-* identifier instead of falling back to test runs. getTestPlan chains the plan-details and linked-runs endpoints into a unified response with metadata, linked test runs, total test-case count across runs, and a derived run-state summary — suitable for generating test documentation and QA status reports. listTestPlans covers discovery when the identifier is not known. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../testmanagement-utils/get-testplan.ts | 155 ++++++++++++++++++ .../testmanagement-utils/list-testplans.ts | 107 ++++++++++++ src/tools/testmanagement.ts | 91 ++++++++++ tests/tools/testmanagement.test.ts | 100 ++++++++++- 4 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 src/tools/testmanagement-utils/get-testplan.ts create mode 100644 src/tools/testmanagement-utils/list-testplans.ts diff --git a/src/tools/testmanagement-utils/get-testplan.ts b/src/tools/testmanagement-utils/get-testplan.ts new file mode 100644 index 00000000..9e7e9231 --- /dev/null +++ b/src/tools/testmanagement-utils/get-testplan.ts @@ -0,0 +1,155 @@ +import { apiClient } from "../../lib/apiClient.js"; +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { formatAxiosError } from "../../lib/error.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { getTMBaseURL } from "../../lib/tm-base-url.js"; + +/** + * Schema for fetching a single test plan by identifier, including its linked test runs. + */ +export const GetTestPlanSchema = z.object({ + project_identifier: z + .string() + .describe( + "Identifier of the project (starts with PR- followed by a number).", + ), + test_plan_identifier: z + .string() + .describe( + "Identifier of the test plan (starts with TP- followed by a number).", + ), +}); + +export type GetTestPlanArgs = z.infer; + +interface TestPlan { + identifier: string; + name: string; + active_state: string; + description: string | null; + project_id: string; + start_date: string | null; + end_date: string | null; + created_at: string; + test_runs_count?: { active: number; closed: number }; + test_runs?: Array<{ identifier: string; name: string }>; + links?: Record; +} + +interface LinkedTestRun { + identifier: string; + name: string; + run_state: string; + active_state: string; + assignee?: string | null; + description?: string | null; + created_at: string; + project_id: string; + test_cases_count: number; +} + +/** + * Fetches a test plan by identifier and its linked test runs, returning a unified view + * suitable for generating documentation (metadata + linked runs + status summary + case count). + */ +export async function getTestPlan( + args: GetTestPlanArgs, + config: BrowserStackConfig, +): Promise { + try { + const tmBaseUrl = await getTMBaseURL(config); + const projectId = encodeURIComponent(args.project_identifier); + const planId = encodeURIComponent(args.test_plan_identifier); + + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const authHeader = + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); + + const planResp = await apiClient.get({ + url: `${tmBaseUrl}/api/v2/projects/${projectId}/test-plans/${planId}`, + headers: { Authorization: authHeader }, + }); + + if (!planResp.data?.success) { + return { + content: [ + { + type: "text", + text: `Failed to fetch test plan: ${JSON.stringify(planResp.data)}`, + }, + ], + isError: true, + }; + } + + const plan: TestPlan = planResp.data.test_plan; + + const runsResp = await apiClient.get({ + url: `${tmBaseUrl}/api/v2/projects/${projectId}/test-plans/${planId}/test-runs`, + headers: { Authorization: authHeader }, + }); + + const runs: LinkedTestRun[] = runsResp.data?.success + ? (runsResp.data.test_runs ?? []) + : []; + + const statusSummary: Record = {}; + let totalCases = 0; + for (const run of runs) { + statusSummary[run.run_state] = (statusSummary[run.run_state] ?? 0) + 1; + totalCases += run.test_cases_count ?? 0; + } + + const header = [ + `Test Plan ${plan.identifier}: ${plan.name}`, + `Status: ${plan.active_state}`, + plan.description ? `Description: ${plan.description}` : null, + plan.start_date || plan.end_date + ? `Dates: ${plan.start_date ?? "—"} → ${plan.end_date ?? "—"}` + : null, + `Linked runs: ${runs.length} (plan counts — active ${plan.test_runs_count?.active ?? 0} / closed ${plan.test_runs_count?.closed ?? 0})`, + `Total test cases across runs: ${totalCases}`, + Object.keys(statusSummary).length > 0 + ? `Run-state breakdown: ${Object.entries(statusSummary) + .map(([s, n]) => `${s}=${n}`) + .join(", ")}` + : null, + ] + .filter(Boolean) + .join("\n"); + + const runsBlock = runs.length + ? "\n\nLinked test runs:\n" + + runs + .map( + (r) => + `• ${r.identifier}: ${r.name} [${r.run_state}] — ${r.test_cases_count} case(s)${r.assignee ? ` (assignee: ${r.assignee})` : ""}`, + ) + .join("\n") + : "\n\nNo test runs linked to this plan."; + + return { + content: [ + { type: "text", text: header + runsBlock }, + { + type: "text", + text: JSON.stringify( + { + test_plan: plan, + linked_test_runs: runs, + status_summary: statusSummary, + total_test_cases: totalCases, + }, + null, + 2, + ), + }, + ], + }; + } catch (err) { + return formatAxiosError(err, "Failed to fetch test plan"); + } +} diff --git a/src/tools/testmanagement-utils/list-testplans.ts b/src/tools/testmanagement-utils/list-testplans.ts new file mode 100644 index 00000000..d43dbf80 --- /dev/null +++ b/src/tools/testmanagement-utils/list-testplans.ts @@ -0,0 +1,107 @@ +import { apiClient } from "../../lib/apiClient.js"; +import { z } from "zod"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { formatAxiosError } from "../../lib/error.js"; +import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import { getTMBaseURL } from "../../lib/tm-base-url.js"; + +/** + * Schema for listing test plans in a BrowserStack Test Management project. + */ +export const ListTestPlansSchema = z.object({ + project_identifier: z + .string() + .describe( + "Identifier of the project to fetch test plans from (starts with PR- followed by a number).", + ), + p: z.number().optional().describe("Page number."), +}); + +export type ListTestPlansArgs = z.infer; + +interface TestPlanListItem { + identifier: string; + name: string; + active_state: string; + description: string | null; + project_id: string; + start_date: string | null; + end_date: string | null; + created_at: string; + test_runs_count?: { active: number; closed: number }; +} + +/** + * Lists test plans for a project in BrowserStack Test Management. + */ +export async function listTestPlans( + args: ListTestPlansArgs, + config: BrowserStackConfig, +): Promise { + try { + const params = new URLSearchParams(); + if (args.p !== undefined) params.append("p", args.p.toString()); + + const tmBaseUrl = await getTMBaseURL(config); + const projectId = encodeURIComponent(args.project_identifier); + const url = `${tmBaseUrl}/api/v2/projects/${projectId}/test-plans?${params.toString()}`; + + const authString = getBrowserStackAuth(config); + const [username, password] = authString.split(":"); + const resp = await apiClient.get({ + url, + headers: { + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + }, + }); + + const data = resp.data; + if (!data.success) { + return { + content: [ + { + type: "text", + text: `Failed to list test plans: ${JSON.stringify(data)}`, + }, + ], + isError: true, + }; + } + + const plans: TestPlanListItem[] = data.test_plans ?? []; + const info = data.info ?? {}; + const count = info.count ?? plans.length; + + if (plans.length === 0) { + return { + content: [ + { + type: "text", + text: `No test plans found in project ${args.project_identifier}.`, + }, + ], + }; + } + + const summary = plans + .map( + (p) => + `• ${p.identifier}: ${p.name} [${p.active_state}] — ${p.test_runs_count?.active ?? 0} active / ${p.test_runs_count?.closed ?? 0} closed run(s)`, + ) + .join("\n"); + + return { + content: [ + { + type: "text", + text: `Found ${count} test plan(s) in project ${args.project_identifier}:\n\n${summary}`, + }, + { type: "text", text: JSON.stringify(plans, null, 2) }, + ], + }; + } catch (err) { + return formatAxiosError(err, "Failed to list test plans"); + } +} diff --git a/src/tools/testmanagement.ts b/src/tools/testmanagement.ts index f3044967..670fe3ce 100644 --- a/src/tools/testmanagement.ts +++ b/src/tools/testmanagement.ts @@ -57,6 +57,17 @@ import { createLCASteps, CreateLCAStepsSchema, } from "./testmanagement-utils/create-lca-steps.js"; + +import { + listTestPlans, + ListTestPlansSchema, +} from "./testmanagement-utils/list-testplans.js"; + +import { + getTestPlan, + GetTestPlanSchema, +} from "./testmanagement-utils/get-testplan.js"; + import { BrowserStackConfig } from "../lib/types.js"; //TODO: Moving the traceMCP and catch block to the parent(server) function @@ -432,6 +443,72 @@ export async function createLCAStepsTool( } } +/** + * Lists test plans in a project. + */ +export async function listTestPlansTool( + args: z.infer, + config: BrowserStackConfig, + server: McpServer, +): Promise { + try { + trackMCP( + "listTestPlans", + server.server.getClientVersion()!, + undefined, + config, + ); + return await listTestPlans(args, config); + } catch (err) { + logger.error("Failed to list test plans: %s", err); + trackMCP("listTestPlans", server.server.getClientVersion()!, err, config); + return { + content: [ + { + type: "text", + text: `Failed to list test plans: ${ + err instanceof Error ? err.message : "Unknown error" + }. Please open an issue on GitHub if the problem persists`, + }, + ], + isError: true, + }; + } +} + +/** + * Fetches a test plan by identifier, with its linked runs and a derived status summary. + */ +export async function getTestPlanTool( + args: z.infer, + config: BrowserStackConfig, + server: McpServer, +): Promise { + try { + trackMCP( + "getTestPlan", + server.server.getClientVersion()!, + undefined, + config, + ); + return await getTestPlan(args, config); + } catch (err) { + logger.error("Failed to fetch test plan: %s", err); + trackMCP("getTestPlan", server.server.getClientVersion()!, err, config); + return { + content: [ + { + type: "text", + text: `Failed to fetch test plan: ${ + err instanceof Error ? err.message : "Unknown error" + }. Please open an issue on GitHub if the problem persists`, + }, + ], + isError: true, + }; + } +} + /** * Registers both project/folder and test-case tools. */ @@ -519,5 +596,19 @@ export default function addTestManagementTools( (args, context) => createLCAStepsTool(args, context, config, server), ); + tools.listTestPlans = server.tool( + "listTestPlans", + "List test plans in a BrowserStack Test Management project. Returns each plan's identifier (TP-*), name, status, description, dates, and active/closed test-run counts. Supports pagination.", + ListTestPlansSchema.shape, + (args) => listTestPlansTool(args, config, server), + ); + + tools.getTestPlan = server.tool( + "getTestPlan", + "Fetch a test plan by identifier (TP-*) from BrowserStack Test Management. Returns plan metadata, the full list of linked test runs, total test-case count across runs, and a status summary — suitable for generating test documentation or QA status reports.", + GetTestPlanSchema.shape, + (args) => getTestPlanTool(args, config, server), + ); + return tools; } diff --git a/tests/tools/testmanagement.test.ts b/tests/tools/testmanagement.test.ts index f1f5b7bf..613bdc0f 100644 --- a/tests/tools/testmanagement.test.ts +++ b/tests/tools/testmanagement.test.ts @@ -8,7 +8,9 @@ import { uploadProductRequirementFileTool, createTestCasesFromFileTool, createLCAStepsTool, - listTestCasesTool + listTestCasesTool, + listTestPlansTool, + getTestPlanTool, } from '../../src/tools/testmanagement'; import addTestManagementTools from '../../src/tools/testmanagement'; import { createProjectOrFolder } from '../../src/tools/testmanagement-utils/create-project-folder'; @@ -18,6 +20,8 @@ import { createTestRun } from '../../src/tools/testmanagement-utils/create-testr import { addTestResult } from '../../src/tools/testmanagement-utils/add-test-result'; import { listTestRuns } from '../../src/tools/testmanagement-utils/list-testruns'; import { updateTestRun } from '../../src/tools/testmanagement-utils/update-testrun'; +import { listTestPlans } from '../../src/tools/testmanagement-utils/list-testplans'; +import { getTestPlan } from '../../src/tools/testmanagement-utils/get-testplan'; import { createTestCasesFromFile } from '../../src/tools/testmanagement-utils/testcase-from-file'; import { createLCASteps } from '../../src/tools/testmanagement-utils/create-lca-steps'; import axios from 'axios'; @@ -147,6 +151,20 @@ vi.mock('../../src/tools/testmanagement-utils/list-testcases', () => ({ shape: {}, }, })); +vi.mock('../../src/tools/testmanagement-utils/list-testplans', () => ({ + listTestPlans: vi.fn(), + ListTestPlansSchema: { + parse: (args: any) => args, + shape: {}, + }, +})); +vi.mock('../../src/tools/testmanagement-utils/get-testplan', () => ({ + getTestPlan: vi.fn(), + GetTestPlanSchema: { + parse: (args: any) => args, + shape: {}, + }, +})); const mockedAxios = axios as Mocked; @@ -692,3 +710,83 @@ vi.mock('../../src/tools/testmanagement-utils/upload-file', () => { }); // Get the mocked uploadFile + +describe('listTestPlansTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const projectId = 'PR-127'; + const mockPlans = [ + { identifier: 'TP-25', name: 'Release 1.0', active_state: 'active' }, + { identifier: 'TP-26', name: 'Release 2.0', active_state: 'active' }, + ]; + + it('should return summary and raw JSON on success', async () => { + (listTestPlans as Mock).mockResolvedValue({ + content: [ + { type: 'text', text: 'Found 2 test plan(s) in project PR-127:\n\n• TP-25: Release 1.0 [active]' }, + { type: 'text', text: JSON.stringify(mockPlans, null, 2) }, + ], + isError: false, + }); + const result = await listTestPlansTool({ project_identifier: projectId }, mockConfig, mockServer); + expect(result.isError).toBe(false); + expect(result.content?.[0]?.text).toContain('Found 2 test plan(s)'); + expect(result.content?.[1]?.text).toBe(JSON.stringify(mockPlans, null, 2)); + }); + + it('should handle errors', async () => { + (listTestPlans as Mock).mockRejectedValue(new Error('Network Error')); + const result = await listTestPlansTool({ project_identifier: projectId }, mockConfig, mockServer); + expect(result.isError).toBe(true); + expect(result.content?.[0]?.text).toContain('Failed to list test plans: Network Error'); + }); +}); + +describe('getTestPlanTool', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const args = { + project_identifier: 'PR-127', + test_plan_identifier: 'TP-25', + }; + + it('should return plan details, linked runs and status summary on success', async () => { + const payload = { + test_plan: { + identifier: 'TP-25', + name: 'Release 1.0', + active_state: 'active', + description: 'Plan for Release 1.0', + }, + linked_test_runs: [ + { identifier: 'TR-1', name: 'Run One', run_state: 'new_run', test_cases_count: 10 }, + { identifier: 'TR-2', name: 'Run Two', run_state: 'done', test_cases_count: 5 }, + ], + status_summary: { new_run: 1, done: 1 }, + total_test_cases: 15, + }; + (getTestPlan as Mock).mockResolvedValue({ + content: [ + { type: 'text', text: 'Test Plan TP-25: Release 1.0\nStatus: active\nTotal test cases across runs: 15' }, + { type: 'text', text: JSON.stringify(payload, null, 2) }, + ], + isError: false, + }); + const result = await getTestPlanTool(args, mockConfig, mockServer); + expect(result.isError).toBe(false); + expect(result.content?.[0]?.text).toContain('TP-25'); + expect(result.content?.[0]?.text).toContain('Total test cases across runs: 15'); + expect(result.content?.[1]?.text).toBe(JSON.stringify(payload, null, 2)); + }); + + it('should handle errors', async () => { + (getTestPlan as Mock).mockRejectedValue(new Error('Not Found')); + const result = await getTestPlanTool(args, mockConfig, mockServer); + expect(result.isError).toBe(true); + expect(result.content?.[0]?.text).toContain('Failed to fetch test plan: Not Found'); + }); +});