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
179 changes: 166 additions & 13 deletions src/tools/testmanagement-utils/create-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TestCaseCreateRequest {
automation_status?: string;
priority?: string;
template?: string;
template_id?: number;
}

export interface TestCaseResponse {
Expand All @@ -60,6 +61,7 @@ export interface TestCaseResponse {
}>;
tags: string[];
template: string;
template_id?: number;
description: string;
preconditions: string;
title: string;
Expand Down Expand Up @@ -160,7 +162,13 @@ export const CreateTestCaseSchema = z.object({
.string()
.optional()
.describe(
"Template internal slug, e.g. 'test_case_steps' or 'test_case_bdd'. Use the slug, not the display name.",
"System template slug only: 'test_case_steps' or 'test_case_bdd'. For a custom template, use template_id instead.",
),
template_id: z
.number()
.optional()
.describe(
"Numeric ID of a custom template (from listTestCaseTemplates); applies that template. Overrides 'template'.",
),
});

Expand All @@ -172,6 +180,7 @@ export function sanitizeArgs(args: any) {
if (cleaned.preconditions === null) delete cleaned.preconditions;
if (cleaned.automation_status === null) delete cleaned.automation_status;
if (cleaned.template === null) delete cleaned.template;
if (cleaned.template_id === null) delete cleaned.template_id;

if (cleaned.issue_tracker) {
if (
Expand Down Expand Up @@ -218,6 +227,91 @@ async function normalizePriority(
}
}

/**
* Read a freshly-created test case back to learn which template was actually
* applied. The create response does not echo template_id, but the v1 search
* endpoint does. Returns undefined on any failure (caller then skips the
* verification warning rather than blocking the success path).
*/
async function fetchAppliedTemplateId(
numericProjectId: string,
identifier: string,
config: BrowserStackConfig,
): Promise<number | undefined> {
try {
const tmBaseUrl = await getTMBaseURL(config);
const resp = await apiClient.get({
url: `${tmBaseUrl}/api/v1/projects/${encodeURIComponent(
numericProjectId,
)}/test-cases/search?q%5Bquery%5D=${encodeURIComponent(identifier)}`,
headers: {
"API-TOKEN": getBrowserStackAuth(config),
accept: "application/json, text/plain, */*",
},
});
const cases: Array<{ identifier?: string; template_id?: number }> =
resp.data?.test_cases ?? [];
const match = cases.find((c) => c.identifier === identifier);
return match?.template_id;
} catch {
return undefined;
}
}

/**
* The v1 create endpoint (used when a template_id is requested) keys
* custom_fields by numeric field id with option *ids* — unlike the v2 endpoint,
* which keys by field name with option *values*. Translate the MCP's by-name
* shape into v1's by-id shape using the project's form fields. Best-effort:
* unknown fields/options pass through unchanged.
*/
async function toV1CustomFields(
customFields: Record<string, CustomFieldValue>,
numericProjectId: string,
config: BrowserStackConfig,
): Promise<Record<string, CustomFieldValue>> {
let defs: any[] = [];
try {
const formFields = await fetchFormFields(numericProjectId, config);
defs = Array.isArray(formFields?.custom_fields)
? formFields.custom_fields
: [];
} catch {
return customFields;
}

const byName = new Map<string, any>(defs.map((f) => [f.field_name, f]));
const out: Record<string, CustomFieldValue> = {};

for (const [name, value] of Object.entries(customFields)) {
const def = byName.get(name);
if (!def) {
out[name] = value; // unknown field name — leave as-is
continue;
}
const isOptionField =
def.field_type === "field_dropdown" ||
def.field_type === "field_multi_dropdown";
if (isOptionField) {
const optionIdByValue = new Map<string, string | number>();
for (const o of (def.option_values ?? []) as Array<{
option_value: string | number;
id: string | number;
}>) {
optionIdByValue.set(String(o.option_value), o.id);
}
const toOptionId = (v: string | number): string | number =>
optionIdByValue.get(String(v)) ?? v;
out[String(def.id)] = Array.isArray(value)
? value.map(toOptionId)
: toOptionId(value as string | number);
} else {
out[String(def.id)] = value;
}
}
return out;
}

export async function createTestCase(
params: TestCaseCreateRequest,
config: BrowserStackConfig,
Expand All @@ -232,23 +326,62 @@ export async function createTestCase(
);
}

const body = { test_case: testCaseParams };
const authString = getBrowserStackAuth(config);
const [username, password] = authString.split(":");

try {
const tmBaseUrl = await getTMBaseURL(config);
const response = await apiClient.post({
url: `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(

// The public v2 create endpoint silently drops template_id, so a specific
// (custom) template cannot be applied through it. The v1 create endpoint
// DOES honour template_id — but it needs the numeric project id, the folder
// in the body, API-TOKEN auth, and custom_fields keyed by id. Use v1 only
// when a template_id is requested; otherwise keep the proven v2 path so
// existing behaviour (incl. custom_fields by name) is unchanged.
let request: { url: string; headers: Record<string, string>; body: any };
if (testCaseParams.template_id !== undefined) {
const numericProjectId = await projectIdentifierToId(
params.project_identifier,
)}/folders/${encodeURIComponent(params.folder_id)}/test-cases`,
headers: {
"Content-Type": "application/json",
Authorization:
"Basic " + Buffer.from(`${username}:${password}`).toString("base64"),
},
body,
});
config,
);
const v1TestCase: Record<string, any> = { ...testCaseParams };
delete v1TestCase.project_identifier;
delete v1TestCase.folder_id;
delete v1TestCase.custom_fields;
v1TestCase.test_case_folder_id = Number(params.folder_id);
if (testCaseParams.custom_fields) {
v1TestCase.custom_fields = await toV1CustomFields(
testCaseParams.custom_fields,
numericProjectId,
config,
);
}
request = {
url: `${tmBaseUrl}/api/v1/projects/${encodeURIComponent(
numericProjectId,
)}/test-cases`,
headers: {
"Content-Type": "application/json",
"API-TOKEN": authString,
},
body: { folder_id: Number(params.folder_id), test_case: v1TestCase },
};
} else {
request = {
url: `${tmBaseUrl}/api/v2/projects/${encodeURIComponent(
params.project_identifier,
)}/folders/${encodeURIComponent(params.folder_id)}/test-cases`,
headers: {
"Content-Type": "application/json",
Authorization:
"Basic " +
Buffer.from(`${username}:${password}`).toString("base64"),
},
body: { test_case: testCaseParams },
};
}

const response = await apiClient.post(request);

const { data } = response.data;
if (!data.success) {
Expand All @@ -273,17 +406,37 @@ export async function createTestCase(

const content: Array<{ type: "text"; text: string }> = [];

// A specific custom template is selected by numeric template_id. The create
// response does not echo template_id, so read the case back to learn which
// template was actually applied and warn on mismatch — the public create
// endpoint may silently ignore the requested template.
if (params.template_id !== undefined) {
const appliedId =
tc.template_id !== undefined
? Number(tc.template_id)
: await fetchAppliedTemplateId(projectId, tc.identifier, config);
if (appliedId !== undefined && appliedId !== Number(params.template_id)) {
content.push({
type: "text",
text: `Warning: requested template_id ${params.template_id} was not applied — the test case uses template_id ${appliedId}. Confirm the id via listTestCaseTemplates and that the template is linked to this project.`,
});
}
}

// The TM API silently ignores an unrecognized template slug and falls back
// to the default. Surface that instead of letting it pass as success.
// Note: the `template` slug only ever selects a SYSTEM template; a custom
// template must be selected with template_id.
if (
params.template_id === undefined &&
params.template &&
tc.template &&
String(tc.template).toLowerCase() !==
String(params.template).toLowerCase()
) {
content.push({
type: "text",
text: `Warning: requested template "${params.template}" was not applied — the test case was created with "${tc.template}". BrowserStack expects the template's internal slug (e.g. "test_case_steps", "test_case_bdd") and silently uses the default for unrecognized values.`,
text: `Warning: requested template "${params.template}" was not applied — the test case was created with "${tc.template}". The 'template' field accepts only the system slugs "test_case_steps" or "test_case_bdd"; for a custom template pass template_id (see listTestCaseTemplates).`,
});
}

Expand Down
114 changes: 114 additions & 0 deletions src/tools/testmanagement-utils/list-templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
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-case templates in BrowserStack Test Management.
*/
export const ListTemplatesSchema = z.object({
name: z
.string()
.optional()
.describe("Case-insensitive substring filter on template name."),
});

export type ListTemplatesArgs = z.infer<typeof ListTemplatesSchema>;

interface TemplateResponse {
id: number;
name: string;
step_type: string;
is_default: boolean;
is_system: boolean;
enabled: boolean;
}

/**
* Lists test-case templates (group-wide) so callers can resolve a template
* name to the numeric template_id.
*
* Custom templates share a step_type (test_case_steps | test_case_bdd) with the
* system templates, so the slug cannot identify them — only the id can. The
* list is account-wide; a template must also be linked to the target project to
* be usable there.
*/
export async function listTemplates(
args: ListTemplatesArgs,
config: BrowserStackConfig,
): Promise<CallToolResult> {
try {
const tmBaseUrl = await getTMBaseURL(config);

// Verified working with API-TOKEN auth (same surface as form-fields-v2).
const resp = await apiClient.get({
url: `${tmBaseUrl}/api/v1/admin-v2/settings/templates?entity_type=TestCase&paginated=false`,
headers: {
"API-TOKEN": getBrowserStackAuth(config),
accept: "application/json, text/plain, */*",
},
});

let templates: TemplateResponse[] = resp.data?.templates ?? [];

if (args.name) {
const needle = args.name.toLowerCase();
templates = templates.filter((t) =>
(t.name ?? "").toLowerCase().includes(needle),
);
}

if (templates.length === 0) {
return {
content: [
{
type: "text",
text: args.name
? `No templates matching "${args.name}".`
: "No templates found.",
},
],
};
}

const summary = templates
.map(
(t) =>
`• [template_id=${t.id}] ${t.name} — step_type=${t.step_type}${
t.is_system ? " (system)" : ""
}${t.is_default ? " (default)" : ""}${
t.enabled === false ? " (disabled)" : ""
}`,
)
.join("\n");

return {
content: [
{
type: "text",
text: `Found ${templates.length} template(s):\n\n${summary}`,
},
{
type: "text",
text: JSON.stringify(
templates.map((t) => ({
template_id: t.id,
name: t.name,
step_type: t.step_type,
is_system: t.is_system,
is_default: t.is_default,
enabled: t.enabled,
})),
null,
2,
),
},
],
};
} catch (err) {
return formatAxiosError(err, "Failed to list templates");
}
}
Loading
Loading