Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions src/tools/testmanagement-utils/get-testplan.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GetTestPlanSchema>;

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<string, string>;
}

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<CallToolResult> {
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<string, number> = {};
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");
}
}
107 changes: 107 additions & 0 deletions src/tools/testmanagement-utils/list-testplans.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ListTestPlansSchema>;

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<CallToolResult> {
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");
}
}
91 changes: 91 additions & 0 deletions src/tools/testmanagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -432,6 +443,72 @@ export async function createLCAStepsTool(
}
}

/**
* Lists test plans in a project.
*/
export async function listTestPlansTool(
args: z.infer<typeof ListTestPlansSchema>,
config: BrowserStackConfig,
server: McpServer,
): Promise<CallToolResult> {
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<typeof GetTestPlanSchema>,
config: BrowserStackConfig,
server: McpServer,
): Promise<CallToolResult> {
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.
*/
Expand Down Expand Up @@ -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;
}
Loading
Loading