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
67 changes: 52 additions & 15 deletions src/tools/testmanagement-utils/create-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ interface IssueTracker {
host: string;
}

// A custom field value may be a scalar or, for multi-select fields, an array
// of option values. The TM API accepts arrays only when keyed by field NAME.
export type CustomFieldValue =
| string
| number
| boolean
| Array<string | number>;

export interface TestCaseCreateRequest {
project_identifier: string;
folder_id: string;
Expand All @@ -32,9 +40,10 @@ export interface TestCaseCreateRequest {
issues?: string[];
issue_tracker?: IssueTracker;
tags?: string[];
custom_fields?: Record<string, string>;
custom_fields?: Record<string, CustomFieldValue>;
automation_status?: string;
priority?: string;
template?: string;
}

export interface TestCaseResponse {
Expand Down Expand Up @@ -66,6 +75,14 @@ export interface TestCaseResponse {
};
}

// Scalar value, or an array of values for multi-select custom fields.
export const customFieldValueSchema = z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.union([z.string(), z.number()])),
]);

export const CreateTestCaseSchema = z.object({
project_identifier: z
.string()
Expand Down Expand Up @@ -122,9 +139,11 @@ export const CreateTestCaseSchema = z.object({
"Tags to attach to the test case. This should be strictly in array format not the string of json",
),
custom_fields: z
.record(z.string(), z.string())
.record(z.string(), customFieldValueSchema)
.optional()
.describe("Map of custom field names to values."),
.describe(
"Map of custom field NAME to value; use an array for multi-select fields.",
),
automation_status: z
.string()
.optional()
Expand All @@ -137,6 +156,12 @@ export const CreateTestCaseSchema = z.object({
.describe(
"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.",
),
template: z
.string()
.optional()
.describe(
"Template internal slug, e.g. 'test_case_steps' or 'test_case_bdd'. Use the slug, not the display name.",
),
});

export function sanitizeArgs(args: any) {
Expand All @@ -146,6 +171,7 @@ export function sanitizeArgs(args: any) {
if (cleaned.owner === null) delete cleaned.owner;
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.issue_tracker) {
if (
Expand Down Expand Up @@ -245,22 +271,33 @@ export async function createTestCase(
config,
);

return {
content: [
{
type: "text",
text: `Test case successfully created:
const content: Array<{ type: "text"; text: string }> = [];

// The TM API silently ignores an unrecognized template slug and falls back
// to the default. Surface that instead of letting it pass as success.
if (
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.`,
});
}

content.push({
type: "text",
text: `Test case successfully created:
- Identifier: ${tc.identifier}
- Title: ${tc.title}

You can view it here: ${tmBaseUrl}/projects/${projectId}/folder/search?q=${tc.identifier}`,
},
{
type: "text",
text: JSON.stringify(tc, null, 2),
},
],
};
});
content.push({ type: "text", text: JSON.stringify(tc, null, 2) });

return { content };
} catch (err) {
// Delegate to our centralized Axios error formatter
return formatAxiosError(err, "Failed to create test case");
Expand Down
17 changes: 14 additions & 3 deletions src/tools/testmanagement-utils/update-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ export interface TestCaseUpdateRequest {
status?: string;
tags?: string[];
issues?: string[];
custom_fields?: Record<string, string | number | boolean>;
custom_fields?: Record<
string,
string | number | boolean | Array<string | number>
>;
}

export const UpdateTestCaseSchema = z.object({
Expand Down Expand Up @@ -101,10 +104,18 @@ export const UpdateTestCaseSchema = z.object({
"Replacement list of linked Jira/Asana/Azure issue IDs for the test case.",
),
custom_fields: z
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
.record(
z.string(),
z.union([
z.string(),
z.number(),
z.boolean(),
z.array(z.union([z.string(), z.number()])),
]),
)
.optional()
.describe(
"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.",
"Map of custom field NAME to value; use an array for multi-select fields.",
),
});

Expand Down
127 changes: 126 additions & 1 deletion tests/tools/testmanagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ vi.mock('../../src/tools/testmanagement-utils/get-sub-testplan', () => ({
// utils at the module level, so apiClient and tm-base-url never get reached
// through real code paths today — adding these mocks is safe.
vi.mock('../../src/lib/apiClient', () => ({
apiClient: { get: vi.fn(), post: vi.fn() },
apiClient: { get: vi.fn(), post: vi.fn(), patch: vi.fn() },
}));
vi.mock('../../src/lib/tm-base-url', () => ({
getTMBaseURL: vi.fn(async () => 'https://test-management.browserstack.com'),
Expand Down Expand Up @@ -1163,3 +1163,128 @@ describe('createTestCase — priority normalization', () => {
expect(body.test_case.priority).toBe('critical');
});
});

// PMAA-131: 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.
describe('createTestCase — template & multi-select custom_fields', () => {
let createTestCaseReal: typeof import('../../src/tools/testmanagement-utils/create-testcase').createTestCase;
let apiClientMock: typeof import('../../src/lib/apiClient').apiClient;

beforeAll(async () => {
const actual = await vi.importActual<
typeof import('../../src/tools/testmanagement-utils/create-testcase')
>('../../src/tools/testmanagement-utils/create-testcase');
createTestCaseReal = actual.createTestCase;
apiClientMock = (await import('../../src/lib/apiClient')).apiClient;
});

beforeEach(() => {
vi.clearAllMocks();
});

const baseArgs = {
project_identifier: 'PR-1',
folder_id: 'F-1',
name: 'Sample',
test_case_steps: [{ step: 'a', result: 'b' }],
};

const resp = (template: string) => ({
data: {
data: {
success: true,
test_case: { identifier: 'TC-1', title: 'Sample', folder_id: 1, template },
},
},
});

it('passes the template slug through to the request body and does not warn when applied', async () => {
(apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_bdd'));

const result = await createTestCaseReal(
{ ...baseArgs, template: 'test_case_bdd' },
mockConfig as any,
);

const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.template).toBe('test_case_bdd');
const text = (result.content ?? []).map((c: any) => c.text).join('\n');
expect(text).not.toContain('was not applied');
});

it('warns when the API silently falls back to a different template', async () => {
(apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps'));

const result = await createTestCaseReal(
{ ...baseArgs, template: 'test_case_sec' },
mockConfig as any,
);

const text = (result.content ?? []).map((c: any) => c.text).join('\n');
expect(text).toContain('was not applied');
expect(text).toContain('test_case_sec');
});

it('passes array (multi-select) custom_fields through to the request body', async () => {
(apiClientMock.post as Mock).mockResolvedValueOnce(resp('test_case_steps'));

await createTestCaseReal(
{ ...baseArgs, 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({ aaas: ['m40', 'm48'] });
});
});

// PMAA-131: 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;

beforeAll(async () => {
const actual = await vi.importActual<
typeof import('../../src/tools/testmanagement-utils/update-testcase')
>('../../src/tools/testmanagement-utils/update-testcase');
updateTestCaseReal = actual.updateTestCase;
apiClientMock = (await import('../../src/lib/apiClient')).apiClient;
});

beforeEach(() => {
vi.clearAllMocks();
});

it('passes array (multi-select) custom_fields through to the PATCH body', async () => {
(apiClientMock.patch as Mock).mockResolvedValueOnce({
data: {
data: {
success: true,
test_case: {
identifier: 'TC-1',
title: 'Sample',
case_type: 'functional',
priority: 'Medium',
status: 'active',
folder_id: 1,
},
},
},
});

const result = await updateTestCaseReal(
{
project_identifier: 'PR-1',
test_case_identifier: 'TC-1',
custom_fields: { aaas: ['m40', 'm48'] },
},
mockConfig as any,
);

expect(result.isError).toBeFalsy();
const body = (apiClientMock.patch as Mock).mock.calls[0][0].body;
expect(body.test_case.custom_fields).toEqual({ aaas: ['m40', 'm48'] });
});
});
Loading