Skip to content

Commit 8b45669

Browse files
Merge pull request #319 from browserstack/feat/testcase-template-multiselect
feat(testmanagement): template selection + multi-select custom fields
2 parents 35eb4af + 1e3b8ad commit 8b45669

3 files changed

Lines changed: 192 additions & 19 deletions

File tree

src/tools/testmanagement-utils/create-testcase.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ interface IssueTracker {
2121
host: string;
2222
}
2323

24+
// A custom field value may be a scalar or, for multi-select fields, an array
25+
// of option values. The TM API accepts arrays only when keyed by field NAME.
26+
export type CustomFieldValue =
27+
| string
28+
| number
29+
| boolean
30+
| Array<string | number>;
31+
2432
export interface TestCaseCreateRequest {
2533
project_identifier: string;
2634
folder_id: string;
@@ -32,9 +40,10 @@ export interface TestCaseCreateRequest {
3240
issues?: string[];
3341
issue_tracker?: IssueTracker;
3442
tags?: string[];
35-
custom_fields?: Record<string, string>;
43+
custom_fields?: Record<string, CustomFieldValue>;
3644
automation_status?: string;
3745
priority?: string;
46+
template?: string;
3847
}
3948

4049
export interface TestCaseResponse {
@@ -66,6 +75,14 @@ export interface TestCaseResponse {
6675
};
6776
}
6877

78+
// Scalar value, or an array of values for multi-select custom fields.
79+
export const customFieldValueSchema = z.union([
80+
z.string(),
81+
z.number(),
82+
z.boolean(),
83+
z.array(z.union([z.string(), z.number()])),
84+
]);
85+
6986
export const CreateTestCaseSchema = z.object({
7087
project_identifier: z
7188
.string()
@@ -122,9 +139,11 @@ export const CreateTestCaseSchema = z.object({
122139
"Tags to attach to the test case. This should be strictly in array format not the string of json",
123140
),
124141
custom_fields: z
125-
.record(z.string(), z.string())
142+
.record(z.string(), customFieldValueSchema)
126143
.optional()
127-
.describe("Map of custom field names to values."),
144+
.describe(
145+
"Map of custom field NAME to value; use an array for multi-select fields.",
146+
),
128147
automation_status: z
129148
.string()
130149
.optional()
@@ -137,6 +156,12 @@ export const CreateTestCaseSchema = z.object({
137156
.describe(
138157
"Priority of the test case. Accepts either display name (e.g. 'Critical', 'High', 'Medium', 'Low') or internal name (e.g. 'medium'). If omitted, the project default (usually 'Medium') is applied. Valid values are per-project and discoverable via the form-fields endpoint.",
139158
),
159+
template: z
160+
.string()
161+
.optional()
162+
.describe(
163+
"Template internal slug, e.g. 'test_case_steps' or 'test_case_bdd'. Use the slug, not the display name.",
164+
),
140165
});
141166

142167
export function sanitizeArgs(args: any) {
@@ -146,6 +171,7 @@ export function sanitizeArgs(args: any) {
146171
if (cleaned.owner === null) delete cleaned.owner;
147172
if (cleaned.preconditions === null) delete cleaned.preconditions;
148173
if (cleaned.automation_status === null) delete cleaned.automation_status;
174+
if (cleaned.template === null) delete cleaned.template;
149175

150176
if (cleaned.issue_tracker) {
151177
if (
@@ -245,22 +271,33 @@ export async function createTestCase(
245271
config,
246272
);
247273

248-
return {
249-
content: [
250-
{
251-
type: "text",
252-
text: `Test case successfully created:
274+
const content: Array<{ type: "text"; text: string }> = [];
275+
276+
// The TM API silently ignores an unrecognized template slug and falls back
277+
// to the default. Surface that instead of letting it pass as success.
278+
if (
279+
params.template &&
280+
tc.template &&
281+
String(tc.template).toLowerCase() !==
282+
String(params.template).toLowerCase()
283+
) {
284+
content.push({
285+
type: "text",
286+
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.`,
287+
});
288+
}
289+
290+
content.push({
291+
type: "text",
292+
text: `Test case successfully created:
253293
- Identifier: ${tc.identifier}
254294
- Title: ${tc.title}
255295
256296
You can view it here: ${tmBaseUrl}/projects/${projectId}/folder/search?q=${tc.identifier}`,
257-
},
258-
{
259-
type: "text",
260-
text: JSON.stringify(tc, null, 2),
261-
},
262-
],
263-
};
297+
});
298+
content.push({ type: "text", text: JSON.stringify(tc, null, 2) });
299+
300+
return { content };
264301
} catch (err) {
265302
// Delegate to our centralized Axios error formatter
266303
return formatAxiosError(err, "Failed to create test case");

src/tools/testmanagement-utils/update-testcase.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,10 @@ export interface TestCaseUpdateRequest {
2929
status?: string;
3030
tags?: string[];
3131
issues?: string[];
32-
custom_fields?: Record<string, string | number | boolean>;
32+
custom_fields?: Record<
33+
string,
34+
string | number | boolean | Array<string | number>
35+
>;
3336
}
3437

3538
export const UpdateTestCaseSchema = z.object({
@@ -101,10 +104,18 @@ export const UpdateTestCaseSchema = z.object({
101104
"Replacement list of linked Jira/Asana/Azure issue IDs for the test case.",
102105
),
103106
custom_fields: z
104-
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
107+
.record(
108+
z.string(),
109+
z.union([
110+
z.string(),
111+
z.number(),
112+
z.boolean(),
113+
z.array(z.union([z.string(), z.number()])),
114+
]),
115+
)
105116
.optional()
106117
.describe(
107-
"Map of custom field name/id to value. Valid field names and value types are per-project; discover them via the project's form fields.",
118+
"Map of custom field NAME to value; use an array for multi-select fields.",
108119
),
109120
});
110121

tests/tools/testmanagement.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ vi.mock('../../src/tools/testmanagement-utils/get-sub-testplan', () => ({
190190
// utils at the module level, so apiClient and tm-base-url never get reached
191191
// through real code paths today — adding these mocks is safe.
192192
vi.mock('../../src/lib/apiClient', () => ({
193-
apiClient: { get: vi.fn(), post: vi.fn() },
193+
apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn() },
194194
}));
195195
vi.mock('../../src/lib/tm-base-url', () => ({
196196
getTMBaseURL: vi.fn(async () => 'https://test-management.browserstack.com'),
@@ -1163,3 +1163,128 @@ describe('createTestCase — priority normalization', () => {
11631163
expect(body.test_case.priority).toBe('critical');
11641164
});
11651165
});
1166+
1167+
// PMAA-131: template slug pass-through + multi-select custom fields.
1168+
// Behaviour verified against the live TM v2 API: the create endpoint keys on
1169+
// the template's internal slug and silently falls back to the default for
1170+
// unrecognized values; multi-select custom fields accept arrays keyed by name.
1171+
describe('createTestCase — template & multi-select custom_fields', () => {
1172+
let createTestCaseReal: typeof import('../../src/tools/testmanagement-utils/create-testcase').createTestCase;
1173+
let apiClientMock: typeof import('../../src/lib/apiClient').apiClient;
1174+
1175+
beforeAll(async () => {
1176+
const actual = await vi.importActual<
1177+
typeof import('../../src/tools/testmanagement-utils/create-testcase')
1178+
>('../../src/tools/testmanagement-utils/create-testcase');
1179+
createTestCaseReal = actual.createTestCase;
1180+
apiClientMock = (await import('../../src/lib/apiClient')).apiClient;
1181+
});
1182+
1183+
beforeEach(() => {
1184+
vi.clearAllMocks();
1185+
});
1186+
1187+
const baseArgs = {
1188+
project_identifier: 'PR-1',
1189+
folder_id: 'F-1',
1190+
name: 'Sample',
1191+
test_case_steps: [{ step: 'a', result: 'b' }],
1192+
};
1193+
1194+
const resp = (template: string) => ({
1195+
data: {
1196+
data: {
1197+
success: true,
1198+
test_case: { identifier: 'TC-1', title: 'Sample', folder_id: 1, template },
1199+
},
1200+
},
1201+
});
1202+
1203+
it('passes the template slug through to the request body and does not warn when applied', async () => {
1204+
(apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_bdd'));
1205+
1206+
const result = await createTestCaseReal(
1207+
{ ...baseArgs, template: 'test_case_bdd' },
1208+
mockConfig as any,
1209+
);
1210+
1211+
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
1212+
expect(body.test_case.template).toBe('test_case_bdd');
1213+
const text = (result.content ?? []).map((c: any) => c.text).join('\n');
1214+
expect(text).not.toContain('was not applied');
1215+
});
1216+
1217+
it('warns when the API silently falls back to a different template', async () => {
1218+
(apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps'));
1219+
1220+
const result = await createTestCaseReal(
1221+
{ ...baseArgs, template: 'test_case_sec' },
1222+
mockConfig as any,
1223+
);
1224+
1225+
const text = (result.content ?? []).map((c: any) => c.text).join('\n');
1226+
expect(text).toContain('was not applied');
1227+
expect(text).toContain('test_case_sec');
1228+
});
1229+
1230+
it('passes array (multi-select) custom_fields through to the request body', async () => {
1231+
(apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps'));
1232+
1233+
await createTestCaseReal(
1234+
{ ...baseArgs, custom_fields: { aaas: ['m40', 'm48'] } },
1235+
mockConfig as any,
1236+
);
1237+
1238+
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
1239+
expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] });
1240+
});
1241+
});
1242+
1243+
// PMAA-131: multi-select custom fields on the update (PATCH) path.
1244+
describe('updateTestCase — multi-select custom_fields', () => {
1245+
let updateTestCaseReal: typeof import('../../src/tools/testmanagement-utils/update-testcase').updateTestCase;
1246+
let apiClientMock: typeof import('../../src/lib/apiClient').apiClient;
1247+
1248+
beforeAll(async () => {
1249+
const actual = await vi.importActual<
1250+
typeof import('../../src/tools/testmanagement-utils/update-testcase')
1251+
>('../../src/tools/testmanagement-utils/update-testcase');
1252+
updateTestCaseReal = actual.updateTestCase;
1253+
apiClientMock = (await import('../../src/lib/apiClient')).apiClient;
1254+
});
1255+
1256+
beforeEach(() => {
1257+
vi.clearAllMocks();
1258+
});
1259+
1260+
it('passes array (multi-select) custom_fields through to the PATCH body', async () => {
1261+
(apiClientMock.patch as Mock).mockResolvedValueOnce({
1262+
data: {
1263+
data: {
1264+
success: true,
1265+
test_case: {
1266+
identifier: 'TC-1',
1267+
title: 'Sample',
1268+
case_type: 'functional',
1269+
priority: 'Medium',
1270+
status: 'active',
1271+
folder_id: 1,
1272+
},
1273+
},
1274+
},
1275+
});
1276+
1277+
const result = await updateTestCaseReal(
1278+
{
1279+
project_identifier: 'PR-1',
1280+
test_case_identifier: 'TC-1',
1281+
custom_fields: { aaas: ['m40', 'm48'] },
1282+
},
1283+
mockConfig as any,
1284+
);
1285+
1286+
expect(result.isError).toBeFalsy();
1287+
const body = (apiClientMock.patch as Mock).mock.calls[0][0].body;
1288+
expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] });
1289+
});
1290+
});

0 commit comments

Comments
 (0)