From 3939fb7df51509c349d862d9221757a74866bf43 Mon Sep 17 00:00:00 2001 From: Gaurav Singh Date: Thu, 25 Jun 2026 19:35:48 +0530 Subject: [PATCH] feat(testmanagement): create test cases with a custom template + listTestCaseTemplates Follow-up to #319, which added a `template` param that only ever selects a SYSTEM template (the TM create logic maps the slug to {is_system, step_type}, and step_type is constrained to test_case_steps/test_case_bdd and isn't unique). A custom template is selectable only by its numeric template_id. Two capabilities, both verified end-to-end against prod: - listTestCaseTemplates: lists templates with their numeric ids via GET /api/v1/admin-v2/settings/templates?entity_type=TestCase (API-TOKEN auth), with a client-side name filter. - createTestCase template_id: the public v2 create endpoint silently drops template_id, but the v1 create endpoint honours it. When template_id is set we route to POST /api/v1/projects/{numericId}/test-cases (API-TOKEN, folder in the body) and translate custom_fields from the by-name shape (v2) to v1's by-id shape (field name -> id, option value -> option id) so multi-select custom fields keep working alongside a template. With no template_id, the proven v2 path is unchanged (zero regression). A post-create read-back warns if the applied template differs (e.g. id not linked to the project). Verified live: create with template_id + multi-select custom_fields + priority + tags + steps applied all of them. Tests +6. npm run build green (lint, format, tsc, tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../testmanagement-utils/create-testcase.ts | 179 ++++++++++++++-- .../testmanagement-utils/list-templates.ts | 114 ++++++++++ src/tools/testmanagement.ts | 49 +++++ tests/tools/testmanagement.test.ts | 199 +++++++++++++++++- 4 files changed, 526 insertions(+), 15 deletions(-) create mode 100644 src/tools/testmanagement-utils/list-templates.ts diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index 5025578d..ad051335 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -44,6 +44,7 @@ export interface TestCaseCreateRequest { automation_status?: string; priority?: string; template?: string; + template_id?: number; } export interface TestCaseResponse { @@ -60,6 +61,7 @@ export interface TestCaseResponse { }>; tags: string[]; template: string; + template_id?: number; description: string; preconditions: string; title: string; @@ -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'.", ), }); @@ -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 ( @@ -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 { + 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, + numericProjectId: string, + config: BrowserStackConfig, +): Promise> { + 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(defs.map((f) => [f.field_name, f])); + const out: Record = {}; + + 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(); + 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, @@ -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; 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 = { ...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) { @@ -273,9 +406,29 @@ 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() !== @@ -283,7 +436,7 @@ export async function createTestCase( ) { 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).`, }); } diff --git a/src/tools/testmanagement-utils/list-templates.ts b/src/tools/testmanagement-utils/list-templates.ts new file mode 100644 index 00000000..0b54a45a --- /dev/null +++ b/src/tools/testmanagement-utils/list-templates.ts @@ -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; + +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 { + 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"); + } +} diff --git a/src/tools/testmanagement.ts b/src/tools/testmanagement.ts index 82bf86cb..43e6f39e 100644 --- a/src/tools/testmanagement.ts +++ b/src/tools/testmanagement.ts @@ -30,6 +30,11 @@ import { ListFoldersSchema, } from "./testmanagement-utils/list-folders.js"; +import { + listTemplates, + ListTemplatesSchema, +} from "./testmanagement-utils/list-templates.js"; + import { CreateTestRunSchema, createTestRun, @@ -258,6 +263,43 @@ export async function listFoldersTool( } } +/** + * Lists test-case templates so callers can resolve a name to a template_id. + */ +export async function listTemplatesTool( + args: z.infer, + config: BrowserStackConfig, + server: McpServer, +): Promise { + try { + trackMCP( + "listTestCaseTemplates", + server.server.getClientVersion()!, + undefined, + config, + ); + return await listTemplates(args, config); + } catch (err) { + trackMCP( + "listTestCaseTemplates", + server.server.getClientVersion()!, + err, + config, + ); + return { + content: [ + { + type: "text", + text: `Failed to list templates: ${ + err instanceof Error ? err.message : "Unknown error" + }. Please open an issue on GitHub if the problem persists`, + }, + ], + isError: true, + }; + } +} + /** * Creates a test run in BrowserStack Test Management. */ @@ -671,6 +713,13 @@ export default function addTestManagementTools( (args) => listFoldersTool(args, config, server), ); + tools.listTestCaseTemplates = server.tool( + "listTestCaseTemplates", + "List test-case templates with their numeric template_id. Use the id with createTestCase to apply a custom template (the 'template' slug only selects system templates).", + ListTemplatesSchema.shape, + (args) => listTemplatesTool(args, config, server), + ); + tools.createTestRun = server.tool( "createTestRun", "Create a test run in BrowserStack Test Management.", diff --git a/tests/tools/testmanagement.test.ts b/tests/tools/testmanagement.test.ts index dcd6f059..2644e289 100644 --- a/tests/tools/testmanagement.test.ts +++ b/tests/tools/testmanagement.test.ts @@ -1164,7 +1164,7 @@ describe('createTestCase — priority normalization', () => { }); }); -// PMAA-131: template slug pass-through + multi-select custom fields. +// Template slug pass-through + multi-select custom fields. // Behaviour verified against the live TM v2 API: the create endpoint keys on // the template's internal slug and silently falls back to the default for // unrecognized values; multi-select custom fields accept arrays keyed by name. @@ -1238,9 +1238,142 @@ describe('createTestCase — template & multi-select custom_fields', () => { const body = (apiClientMock.post as Mock).mock.calls[0][0].body; expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] }); }); + + // A custom template can only be selected by its numeric template_id; the + // slug always resolves to a system template (two templates share a step_type). + const respWithId = (template_id: number) => ({ + data: { + data: { + success: true, + test_case: { + identifier: 'TC-1', + title: 'Sample', + folder_id: 1, + template: 'test_case_steps', + template_id, + }, + }, + }, + }); + + it('passes template_id through to the request body and does not warn when applied', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(4321)); + + const result = await createTestCaseReal( + { ...baseArgs, template_id: 4321 }, + mockConfig as any, + ); + + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case.template_id).toBe(4321); + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).not.toContain('was not applied'); + }); + + it('warns when the API applies a different template_id than requested', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(1)); + + const result = await createTestCaseReal( + { ...baseArgs, template_id: 4321 }, + mockConfig as any, + ); + + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).toContain('template_id 4321 was not applied'); + expect(text).toContain('template_id 1'); + }); + + it('does not emit the slug warning when template_id is supplied (id takes precedence)', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(4321)); + + const result = await createTestCaseReal( + { ...baseArgs, template: 'test_case_sec', template_id: 4321 }, + mockConfig as any, + ); + + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).not.toContain('The \'template\' field accepts only'); + }); + + it('reads the case back and warns when template_id is omitted from the create response but not applied', async () => { + // Real API: create response carries `template` (step_type) but no template_id. + (apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps')); + // Read-back via v1 search shows the default (2) was applied, not 656127. + (apiClientMock.get as Mock).mockResolvedValueOnce({ + data: { test_cases: [{ identifier: 'TC-1', template_id: 2 }] }, + }); + + const result = await createTestCaseReal( + { ...baseArgs, template_id: 656127 }, + mockConfig as any, + ); + + const getUrl = (apiClientMock.get as Mock).mock.calls[0][0].url; + expect(getUrl).toContain('/test-cases/search'); + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).toContain('template_id 656127 was not applied'); + expect(text).toContain('template_id 2'); + }); + + // template_id is honoured only by the v1 create endpoint (v2 strips it), so a + // template_id request must route to /api/v1/.../test-cases with API-TOKEN auth + // and the folder carried in the body. + it('routes to the v1 create endpoint with API-TOKEN auth when template_id is set', async () => { + (apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(656127)); + + await createTestCaseReal( + { ...baseArgs, folder_id: '50', template_id: 656127 }, + mockConfig as any, + ); + + const call = (apiClientMock.post as Mock).mock.calls[0][0]; + expect(call.url).toContain('/api/v1/projects/'); + expect(call.url).toContain('/test-cases'); + expect(call.url).not.toContain('/folders/'); // folder goes in the body for v1 + expect(call.headers['API-TOKEN']).toBe('fake-user:fake-key'); + expect(call.headers.Authorization).toBeUndefined(); + expect(call.body.folder_id).toBe(50); + expect(call.body.test_case.test_case_folder_id).toBe(50); + expect(call.body.test_case.template_id).toBe(656127); + // MCP-only params must not leak into the v1 test_case payload. + expect(call.body.test_case.project_identifier).toBeUndefined(); + expect(call.body.test_case.folder_id).toBeUndefined(); + }); + + it('translates custom_fields name→id and option value→id on the v1 path', async () => { + const api = await import('../../src/tools/testmanagement-utils/TCG-utils/api'); + (api.fetchFormFields as Mock).mockResolvedValueOnce({ + default_fields: {}, + custom_fields: [ + { + field_name: 'aaas', + id: 194184, + field_type: 'field_multi_dropdown', + option_values: [ + { option_value: 'm40', id: 111 }, + { option_value: 'm48', id: 222 }, + ], + }, + ], + }); + (apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(656127)); + + await createTestCaseReal( + { + ...baseArgs, + folder_id: '50', + template_id: 656127, + custom_fields: { aaas: ['m40', 'm48'] }, + }, + mockConfig as any, + ); + + const body = (apiClientMock.post as Mock).mock.calls[0][0].body; + expect(body.test_case.custom_fields).toEqual({ 194184: [111, 222] }); + }); }); -// PMAA-131: multi-select custom fields on the update (PATCH) path. +// Multi-select custom fields on the update (PATCH) path. describe('updateTestCase — multi-select custom_fields', () => { let updateTestCaseReal: typeof import('../../src/tools/testmanagement-utils/update-testcase').updateTestCase; let apiClientMock: typeof import('../../src/lib/apiClient').apiClient; @@ -1288,3 +1421,65 @@ describe('updateTestCase — multi-select custom_fields', () => { expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] }); }); }); + +// listTestCaseTemplates resolves a template name to a template_id. +describe('listTemplates', () => { + let listTemplatesReal: typeof import('../../src/tools/testmanagement-utils/list-templates').listTemplates; + let apiClientMock: typeof import('../../src/lib/apiClient').apiClient; + + beforeAll(async () => { + const actual = await vi.importActual< + typeof import('../../src/tools/testmanagement-utils/list-templates') + >('../../src/tools/testmanagement-utils/list-templates'); + listTemplatesReal = actual.listTemplates; + apiClientMock = (await import('../../src/lib/apiClient')).apiClient; + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + const templatesResp = { + data: { + success: true, + templates: [ + { id: 2, name: 'Test Case Steps', step_type: 'test_case_steps', is_system: true, is_default: true, enabled: true }, + { id: 42, name: 'Test Case - SEC', step_type: 'test_case_steps', is_system: false, is_default: false, enabled: true }, + ], + }, + }; + + it('queries the admin-v2 templates endpoint with API-TOKEN auth and surfaces ids', async () => { + (apiClientMock.get as Mock).mockResolvedValueOnce(templatesResp); + + const result = await listTemplatesReal({}, mockConfig as any); + + const call = (apiClientMock.get as Mock).mock.calls[0][0]; + expect(call.url).toContain('/api/v1/admin-v2/settings/templates'); + expect(call.url).toContain('entity_type=TestCase'); + expect(call.headers['API-TOKEN']).toBe('fake-user:fake-key'); + + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).toContain('template_id=42'); + expect(text).toContain('Test Case - SEC'); + }); + + it('filters by name client-side', async () => { + (apiClientMock.get as Mock).mockResolvedValueOnce(templatesResp); + + const result = await listTemplatesReal({ name: 'SEC' }, mockConfig as any); + + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).toContain('Test Case - SEC'); + expect(text).not.toContain('[template_id=2]'); + expect(text).toContain('Found 1 template'); + }); + + it('reports when no templates match the name filter', async () => { + (apiClientMock.get as Mock).mockResolvedValueOnce(templatesResp); + + const result = await listTemplatesReal({ name: 'nope' }, mockConfig as any); + const text = (result.content ?? []).map((c: any) => c.text).join('\n'); + expect(text).toContain('No templates matching "nope"'); + }); +});