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
24 changes: 24 additions & 0 deletions src/tools/testmanagement-utils/TCG-utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ export async function fetchFormFields(
return res.data;
}

/**
* Resolve a default-field input (priority/case_type) to the form's display or
* internal name, matching case-insensitively. Returns undefined if no match.
*/
export function normalizeDefaultFieldValue(
fieldValues: Array<{
internal_name?: string | null;
name?: string;
value: any;
}>,
input: string,
emit: "name" | "internal_name",
): string | undefined {
const normalized = input.toLowerCase().trim();
const match = fieldValues.find(
(v) =>
(v.internal_name ?? "").toLowerCase() === normalized ||
(v.name ?? "").toLowerCase() === normalized,
);
if (!match) return undefined;
if (emit === "name") return match.name;
return match.internal_name ?? match.name;
}

/**
* Trigger AI-based test case generation for a document.
*/
Expand Down
57 changes: 55 additions & 2 deletions src/tools/testmanagement-utils/create-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@ import { apiClient } from "../../lib/apiClient.js";
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { formatAxiosError } from "../../lib/error.js";
import { projectIdentifierToId } from "./TCG-utils/api.js";
import {
fetchFormFields,
normalizeDefaultFieldValue,
projectIdentifierToId,
} from "./TCG-utils/api.js";
import { BrowserStackConfig } from "../../lib/types.js";
import { getTMBaseURL } from "../../lib/tm-base-url.js";
import logger from "../../logger.js";

interface TestCaseStep {
step: string;
Expand All @@ -29,6 +34,7 @@ export interface TestCaseCreateRequest {
tags?: string[];
custom_fields?: Record<string, string>;
automation_status?: string;
priority?: string;
}

export interface TestCaseResponse {
Expand Down Expand Up @@ -125,6 +131,12 @@ export const CreateTestCaseSchema = z.object({
.describe(
"Automation status of the test case. Common values include 'not_automated', 'automated', 'automation_not_required'.",
),
priority: z
.string()
.optional()
.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.",
),
});

export function sanitizeArgs(args: any) {
Expand All @@ -149,11 +161,52 @@ 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.
*/
async function normalizePriority(
projectIdentifier: string,
priority: string,
config: BrowserStackConfig,
): Promise<string> {
try {
const numericProjectId = await projectIdentifierToId(
projectIdentifier,
config,
);
const { default_fields } = await fetchFormFields(numericProjectId, config);
return (
normalizeDefaultFieldValue(
default_fields?.priority?.values ?? [],
priority,
"name",
) ?? priority
);
} catch (err) {
logger.warn(
"Failed to normalize priority value; passing through as given: %s",
err instanceof Error ? err.message : String(err),
);
return priority;
}
}

export async function createTestCase(
params: TestCaseCreateRequest,
config: BrowserStackConfig,
): Promise<CallToolResult> {
const body = { test_case: params };
const testCaseParams: TestCaseCreateRequest = { ...params };

if (testCaseParams.priority !== undefined) {
testCaseParams.priority = await normalizePriority(
params.project_identifier,
testCaseParams.priority,
config,
);
}

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

Expand Down
36 changes: 5 additions & 31 deletions src/tools/testmanagement-utils/update-testcase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { apiClient } from "../../lib/apiClient.js";
import { z } from "zod";
import { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { formatAxiosError } from "../../lib/error.js";
import { fetchFormFields, projectIdentifierToId } from "./TCG-utils/api.js";
import {
fetchFormFields,
normalizeDefaultFieldValue,
projectIdentifierToId,
} from "./TCG-utils/api.js";
import { BrowserStackConfig } from "../../lib/types.js";
import { getTMBaseURL } from "../../lib/tm-base-url.js";
import { getBrowserStackAuth } from "../../lib/get-auth.js";
Expand Down Expand Up @@ -104,36 +108,6 @@ export const UpdateTestCaseSchema = z.object({
),
});

/**
* Build a normalizer for a default field's accepted value.
* The TM PATCH endpoint accepts different casings for different default
* fields (Title-Case display name for priority/case_type, snake_case
* internal_name for automation_status). We accept either from the caller
* and emit the form the API actually wants.
*
* Returns undefined when no matching option is found — callers should
* pass the raw value through so the backend can surface its own error.
*/
function normalizeDefaultFieldValue(
fieldValues: Array<{
internal_name?: string | null;
name?: string;
value: any;
}>,
input: string,
emit: "name" | "internal_name",
): string | undefined {
const normalized = input.toLowerCase().trim();
const match = fieldValues.find(
(v) =>
(v.internal_name ?? "").toLowerCase() === normalized ||
(v.name ?? "").toLowerCase() === normalized,
);
if (!match) return undefined;
if (emit === "name") return match.name;
return match.internal_name ?? match.name;
}

/**
* Normalise default-field inputs (priority/case_type/automation_status) to
* what the TM PATCH endpoint accepts. Fetches the project's form-fields
Expand Down
134 changes: 133 additions & 1 deletion tests/tools/testmanagement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ 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'))
projectIdentifierToId: vi.fn(() => Promise.resolve('999')),
fetchFormFields: vi.fn(),
normalizeDefaultFieldValue: vi.fn(),
}));
vi.mock('form-data', () => {
return {
Expand Down Expand Up @@ -193,6 +195,9 @@ vi.mock('../../src/lib/apiClient', () => ({
vi.mock('../../src/lib/tm-base-url', () => ({
getTMBaseURL: vi.fn(async () => 'https://test-management.browserstack.com'),
}));
vi.mock('../../src/logger', () => ({
default: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));


const mockedAxios = axios as Mocked<typeof axios>;
Expand Down Expand Up @@ -1031,3 +1036,130 @@ describe('getSubTestPlan util — fail-soft enrichment', () => {
expect(result.content?.[0]?.text).toContain('Failed to fetch sub-test-plan');
});
});

// Real function via importActual (the module is mocked at the top).
describe('normalizeDefaultFieldValue', () => {
let normalize: typeof import('../../src/tools/testmanagement-utils/TCG-utils/api').normalizeDefaultFieldValue;

beforeAll(async () => {
const actual = await vi.importActual<
typeof import('../../src/tools/testmanagement-utils/TCG-utils/api')
>('../../src/tools/testmanagement-utils/TCG-utils/api');
normalize = actual.normalizeDefaultFieldValue;
});

const values = [
{ name: 'Critical', internal_name: 'critical', value: 1 },
{ name: 'Medium', internal_name: 'medium', value: 2 },
];

it('resolves a lowercase internal name to the Title-Case display name', () => {
expect(normalize(values, 'critical', 'name')).toBe('Critical');
});

it('matches case-insensitively regardless of input casing', () => {
expect(normalize(values, 'CRITICAL', 'name')).toBe('Critical');
expect(normalize(values, ' Critical ', 'name')).toBe('Critical');
});

it('resolves a display name to the internal name when emit is internal_name', () => {
expect(normalize(values, 'Critical', 'internal_name')).toBe('critical');
});

it('returns undefined when no option matches (caller passes raw value through)', () => {
expect(normalize(values, 'urgent', 'name')).toBeUndefined();
expect(normalize([], 'critical', 'name')).toBeUndefined();
});
});

// Real createTestCase via importActual; collaborators are mocked at module scope.
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<
typeof import('../../src/tools/testmanagement-utils/create-testcase')
>('../../src/tools/testmanagement-utils/create-testcase');
createTestCaseReal = actual.createTestCase;
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(() => {
vi.clearAllMocks();
});

// Priority options as returned by fetchFormFields.
const priorityValues = [
{ name: 'Critical', internal_name: 'critical', value: 1 },
{ name: 'Medium', internal_name: 'medium', value: 2 },
];
const formFields = {
default_fields: { priority: { values: priorityValues } },
custom_fields: {},
};
const createSuccess = {
data: {
data: {
success: true,
test_case: {
identifier: 'TC-1',
title: 'Sample',
priority: 'Critical',
folder_id: 1,
},
},
},
};

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

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(
{ ...baseArgs, priority: 'critical' },
mockConfig as any,
);

expect(result.isError).toBeFalsy();
expect(normalizeMock).toHaveBeenCalledWith(priorityValues, 'critical', 'name');
const body = (apiClientMock.post as Mock).mock.calls[0][0].body;
expect(body.test_case.priority).toBe('Critical');
});

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

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

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

it('passes the raw priority 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, priority: 'critical' },
mockConfig as any,
);

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