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
54 changes: 54 additions & 0 deletions src/tools/testmanagement-utils/TCG-utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,60 @@ export function normalizeDefaultFieldValue(
return match.internal_name ?? match.name;
}

export interface DefaultFieldInputs {
priority?: string;
case_type?: string;
automation_status?: string;
}

/**
* Normalize priority/case_type/automation_status against a project's already
* fetched `default_fields`. priority and case_type resolve to the display name
* (the create/update endpoints reject lowercase internal names); on no match
* the raw value passes through. automation_status is special: its values carry
* a null internal_name and hold the internal name in `value`, so it matches on
* name-or-value and emits `value`. Callers own the fetch and its error path.
*/
export function normalizeDefaultFields(
defaultFields: any,
inputs: DefaultFieldInputs,
): DefaultFieldInputs {
const out: DefaultFieldInputs = {};

if (inputs.priority !== undefined) {
out.priority =
normalizeDefaultFieldValue(
defaultFields?.priority?.values ?? [],
inputs.priority,
"name",
) ?? inputs.priority;
}
if (inputs.case_type !== undefined) {
out.case_type =
normalizeDefaultFieldValue(
defaultFields?.case_type?.values ?? [],
inputs.case_type,
"name",
) ?? inputs.case_type;
}
if (inputs.automation_status !== undefined) {
const values =
(defaultFields?.automation_status?.values as Array<{
name?: string;
value?: string;
}>) ?? [];
const input = inputs.automation_status.toLowerCase().trim();
const match = values.find(
(v) =>
(v.value ?? "").toLowerCase() === input ||
(v.name ?? "").toLowerCase() === input,
);
out.automation_status = match?.value ?? inputs.automation_status;
}

return out;
}

/**
* Trigger AI-based test case generation for a document.
*/
Expand Down
48 changes: 30 additions & 18 deletions src/tools/testmanagement-utils/create-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { formatAxiosError } from "../../lib/error.js";
import {
fetchFormFields,
normalizeDefaultFieldValue,
normalizeDefaultFields as normalizeDefaultFieldsFromForm,
projectIdentifierToId,
} from "./TCG-utils/api.js";
import { BrowserStackConfig } from "../../lib/types.js";
Expand Down Expand Up @@ -43,6 +43,7 @@ export interface TestCaseCreateRequest {
custom_fields?: Record<string, CustomFieldValue>;
automation_status?: string;
priority?: string;
case_type?: string;
template?: string;
template_id?: number;
}
Expand Down Expand Up @@ -158,6 +159,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.",
),
case_type: z
.string()
.optional()
.describe(
Comment thread
ruturaj-browserstack marked this conversation as resolved.
"Test case type display or internal name (per-project). Omit for project default.",
),
template: z
.string()
.optional()
Expand Down Expand Up @@ -197,33 +204,28 @@ export function sanitizeArgs(args: any) {
import { getBrowserStackAuth } from "../../lib/get-auth.js";

/**
* Normalize priority to the display name the create endpoint accepts (it
* rejects lowercase). On lookup failure, pass the raw value through.
* Fetch the project's form-fields and normalize priority/case_type to the
* display names the create endpoint accepts (it rejects lowercase internal
* names). On lookup failure, passes raw values through.
*/
async function normalizePriority(
async function normalizeDefaultFields(
projectIdentifier: string,
priority: string,
fields: { priority?: string; case_type?: string },
config: BrowserStackConfig,
): Promise<string> {
): Promise<{ priority?: string; case_type?: string }> {
try {
const numericProjectId = await projectIdentifierToId(
projectIdentifier,
config,
);
const { default_fields } = await fetchFormFields(numericProjectId, config);
return (
normalizeDefaultFieldValue(
default_fields?.priority?.values ?? [],
priority,
"name",
) ?? priority
);
return normalizeDefaultFieldsFromForm(default_fields, fields);
} catch (err) {
logger.warn(
"Failed to normalize priority value; passing through as given: %s",
"Failed to normalize default fields; passing through as given: %s",
err instanceof Error ? err.message : String(err),
);
return priority;
return { priority: fields.priority, case_type: fields.case_type };
}
}

Expand Down Expand Up @@ -318,12 +320,22 @@ export async function createTestCase(
): Promise<CallToolResult> {
const testCaseParams: TestCaseCreateRequest = { ...params };

if (testCaseParams.priority !== undefined) {
testCaseParams.priority = await normalizePriority(
if (
testCaseParams.priority !== undefined ||
testCaseParams.case_type !== undefined
) {
const normalized = await normalizeDefaultFields(
params.project_identifier,
testCaseParams.priority,
{
priority: testCaseParams.priority,
case_type: testCaseParams.case_type,
},
config,
);
if (normalized.priority !== undefined)
testCaseParams.priority = normalized.priority;
if (normalized.case_type !== undefined)
testCaseParams.case_type = normalized.case_type;
}

const authString = getBrowserStackAuth(config);
Expand Down
48 changes: 6 additions & 42 deletions src/tools/testmanagement-utils/update-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { formatAxiosError } from "../../lib/error.js";
import {
fetchFormFields,
normalizeDefaultFieldValue,
normalizeDefaultFields as normalizeDefaultFieldsFromForm,
projectIdentifierToId,
} from "./TCG-utils/api.js";
import { BrowserStackConfig } from "../../lib/types.js";
Expand Down Expand Up @@ -146,47 +146,11 @@ async function normalizeDefaultFields(
);
const { default_fields } = await fetchFormFields(numericProjectId, config);

const out: {
priority?: string;
case_type?: string;
automation_status?: string;
} = {};

if (params.priority !== undefined) {
out.priority =
normalizeDefaultFieldValue(
default_fields?.priority?.values ?? [],
params.priority,
"name",
) ?? params.priority;
}
if (params.case_type !== undefined) {
out.case_type =
normalizeDefaultFieldValue(
default_fields?.case_type?.values ?? [],
params.case_type,
"name",
) ?? params.case_type;
}
if (params.automation_status !== undefined) {
// automation_status.values have null internal_name and the internal
// name is actually held in `value` (see API inspection). Accept
// either the display name or the internal snake_case form.
const values =
(default_fields?.automation_status?.values as Array<{
name?: string;
value?: string;
}>) ?? [];
const input = params.automation_status.toLowerCase().trim();
const match = values.find(
(v) =>
(v.value ?? "").toLowerCase() === input ||
(v.name ?? "").toLowerCase() === input,
);
out.automation_status = match?.value ?? params.automation_status;
}

return out;
return normalizeDefaultFieldsFromForm(default_fields, {
priority: params.priority,
case_type: params.case_type,
automation_status: params.automation_status,
});
} catch (err) {
logger.warn(
"Failed to normalize default field values; passing through as given: %s",
Expand Down
112 changes: 103 additions & 9 deletions tests/tools/testmanagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,17 @@ vi.mock('../../src/lib/inmemory-store', () => ({ signedUrlMap: new Map() }));
vi.mock('../../src/lib/get-auth', () => ({
getBrowserStackAuth: vi.fn(() => 'fake-user:fake-key')
}));
vi.mock('../../src/tools/testmanagement-utils/TCG-utils/api', () => ({
projectIdentifierToId: vi.fn(() => Promise.resolve('999')),
fetchFormFields: vi.fn(),
normalizeDefaultFieldValue: vi.fn(),
}));
vi.mock('../../src/tools/testmanagement-utils/TCG-utils/api', async (importActual) => {
const actual = await importActual<
typeof import('../../src/tools/testmanagement-utils/TCG-utils/api')
>();
return {
...actual,
projectIdentifierToId: vi.fn(() => Promise.resolve('999')),
fetchFormFields: vi.fn(),
normalizeDefaultFieldValue: vi.fn(),
};
});
vi.mock('form-data', () => {
return {
default: vi.fn().mockImplementation(() => ({
Expand Down Expand Up @@ -1077,7 +1083,6 @@ describe('createTestCase — priority normalization', () => {
let createTestCaseReal: typeof import('../../src/tools/testmanagement-utils/create-testcase').createTestCase;
let apiClientMock: typeof import('../../src/lib/apiClient').apiClient;
let fetchFormFieldsMock: Mock;
let normalizeMock: Mock;

beforeAll(async () => {
const actual = await vi.importActual<
Expand All @@ -1087,7 +1092,6 @@ describe('createTestCase — priority normalization', () => {
apiClientMock = (await import('../../src/lib/apiClient')).apiClient;
const api = await import('../../src/tools/testmanagement-utils/TCG-utils/api');
fetchFormFieldsMock = api.fetchFormFields as unknown as Mock;
normalizeMock = api.normalizeDefaultFieldValue as unknown as Mock;
});

beforeEach(() => {
Expand Down Expand Up @@ -1126,7 +1130,6 @@ describe('createTestCase — priority normalization', () => {

it('looks up the project form fields and sends the normalized priority in the request body', async () => {
fetchFormFieldsMock.mockResolvedValue(formFields);
normalizeMock.mockReturnValue('Critical');
(apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess);

const result = await createTestCaseReal(
Expand All @@ -1135,7 +1138,8 @@ describe('createTestCase — priority normalization', () => {
);

expect(result.isError).toBeFalsy();
expect(normalizeMock).toHaveBeenCalledWith(priorityValues, 'critical', 'name');
// Real normalizeDefaultFields maps the internal name 'critical' to the
// display name 'Critical' the create endpoint requires.
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.priority).toBe('Critical');
});
Expand All @@ -1162,6 +1166,72 @@ describe('createTestCase — priority normalization', () => {
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.priority).toBe('critical');
});

const caseTypeValues = [
{ name: 'Functional', internal_name: 'functional', value: 1 },
{ name: 'Regression', internal_name: 'regression', value: 2 },
];

it('looks up the project form fields and sends the normalized case_type in the request body', async () => {
fetchFormFieldsMock.mockResolvedValue({
default_fields: { case_type: { values: caseTypeValues } },
custom_fields: {},
});
(apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess);

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

expect(result.isError).toBeFalsy();
// Real normalizeDefaultFields maps 'functional' to the display name 'Functional'.
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.case_type).toBe('Functional');
});

it('omits case_type from the request body when not provided (preserves project default)', async () => {
(apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess);

await createTestCaseReal({ ...baseArgs }, mockConfig as any);

const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case).not.toHaveProperty('case_type');
});

it('passes the raw case_type through when the form-fields lookup fails (graceful fallback)', async () => {
fetchFormFieldsMock.mockRejectedValue(new Error('Network Error'));
(apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess);

await createTestCaseReal(
{ ...baseArgs, case_type: 'functional' },
mockConfig as any,
);

const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.case_type).toBe('functional');
});

it('normalizes both priority and case_type in a single form-fields lookup', async () => {
fetchFormFieldsMock.mockResolvedValue({
default_fields: {
priority: { values: priorityValues },
case_type: { values: caseTypeValues },
},
custom_fields: {},
});
(apiClientMock.post as Mock).mockResolvedValueOnce(createSuccess);

await createTestCaseReal(
{ ...baseArgs, priority: 'critical', case_type: 'functional' },
mockConfig as any,
);

expect(fetchFormFieldsMock).toHaveBeenCalledTimes(1);
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.priority).toBe('Critical');
expect(body.test_case.case_type).toBe('Functional');
});
});

// Template slug pass-through + multi-select custom fields.
Expand Down Expand Up @@ -1340,6 +1410,30 @@ describe('createTestCase — template & multi-select custom_fields', () => {
expect(call.body.test_case.folder_id).toBeUndefined();
});

// case_type must survive into the v1 test_case body (spread from
// testCaseParams), normalized to the display name the endpoint accepts.
it('carries the normalized case_type into the v1 body when template_id is set', async () => {
const api = await import('../../src/tools/testmanagement-utils/TCG-utils/api');
(api.fetchFormFields as Mock).mockResolvedValue({
default_fields: {
case_type: {
values: [{ name: 'Functional', internal_name: 'functional', value: 1 }],
},
},
custom_fields: [],
});
(apiClientMock.post as Mock).mockResolvedValueOnce(respWithId(656127));

await createTestCaseReal(
{ ...baseArgs, template_id: 656127, case_type: 'functional' },
mockConfig as any,
);

const call = (apiClientMock.post as Mock).mock.calls[0][0];
expect(call.url).toContain('/api/v1/projects/');
expect(call.body.test_case.case_type).toBe('Functional');
});

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({
Expand Down
Loading