diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index 446e2fe..c87a2c0 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -9,6 +9,7 @@ import { } from "./TCG-utils/api.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { getTMBaseURL } from "../../lib/tm-base-url.js"; +import { wrapRichText, wrapTestCaseSteps } from "./rich-text.js"; import logger from "../../logger.js"; interface TestCaseStep { @@ -324,6 +325,16 @@ export async function createTestCase( new Set([...(testCaseParams.tags ?? []), "MCP Generated"]), ); + testCaseParams.test_case_steps = wrapTestCaseSteps( + testCaseParams.test_case_steps, + ); + if (testCaseParams.preconditions !== undefined) { + testCaseParams.preconditions = wrapRichText(testCaseParams.preconditions); + } + if (testCaseParams.description !== undefined) { + testCaseParams.description = wrapRichText(testCaseParams.description); + } + if ( testCaseParams.priority !== undefined || testCaseParams.case_type !== undefined diff --git a/src/tools/testmanagement-utils/rich-text.ts b/src/tools/testmanagement-utils/rich-text.ts new file mode 100644 index 0000000..4b926b0 --- /dev/null +++ b/src/tools/testmanagement-utils/rich-text.ts @@ -0,0 +1,28 @@ +// TM renders these fields as rich text only when the value is HTML; +const TM_ALLOWED_TAG = + /^\s*<(div|img|b|em|i|strong|u|span|abbr|br|cite|code|dd|dfn|dl|dt|kbd|li|mark|ol|p|pre|q|s|samp|small|strike|sub|sup|time|ul|var|table|td|th|thead|tbody|tr|col|colgroup|a)[\s/>]/i; +const HAS_ENCODABLE_CHARS = /[<>&]/; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +export function wrapRichText(text: string): string { + if (TM_ALLOWED_TAG.test(text) || !HAS_ENCODABLE_CHARS.test(text)) { + return text; + } + return `
${escapeHtml(text)}
`; +} + +export function wrapTestCaseStepsNavigate to Settings > Users
', + ); + }); + + it('escapes and wraps bare text containing <', () => { + expect(wrapRichText('if a < b then pass')).toBe( + 'if a < b then pass
', + ); + }); + + it('escapes and wraps bare text containing &', () => { + expect(wrapRichText('Q&A section loads')).toBe( + 'Q&A section loads
', + ); + }); + + it('preserves tag-like tokens that TM would otherwise strip', () => { + expect(wrapRichText('PressPress <Enter> to submit the form
', + ); + expect(wrapRichText('Use ListUse List<String> where A > B & C
', + ); + // Leading tag-like token that is NOT a TM-allowed tag is literal text too. + expect(wrapRichText('<Enter> key submits the form
', + ); + expect(wrapRichText('<h1>not an allowed TM tag</h1>
', + ); + }); + + it('leaves text without <, > or & untouched', () => { + expect(wrapRichText('Click "Save" then \'Confirm\'')).toBe( + 'Click "Save" then \'Confirm\'', + ); + }); + + it('leaves content starting with a TM-allowed tag untouched', () => { + expect(wrapRichText('Settings > Users
')).toBe( + 'Settings > Users
', + ); + expect(wrapRichText('< 5 items are shown
', + ); + }); + + it('handles any characters, not just fixed strings', () => { + expect(wrapRichText('émojis 🎉 and ünïcode ≥ 5 stay bare')).toBe( + 'émojis 🎉 and ünïcode ≥ 5 stay bare', + ); + expect(wrapRichText('unicode plus html char: 温度 > 30°C')).toBe( + 'unicode plus html char: 温度 > 30°C
', + ); + expect(wrapRichText('line1 a > b\nline2 c < d')).toBe( + 'line1 a > b\nline2 c < d
', + ); + expect(wrapRichText('a>=b && c<=d & "e"')).toBe( + 'a>=b && c<=d & "e"
', + ); + }); +}); + +describe('wrapTestCaseSteps', () => { + it('wraps step and result independently and preserves other keys', () => { + expect( + wrapTestCaseSteps([ + { step: 'Go to A > B', result: 'B page loads' }, + { step: 'Click Save', result: 'Count > 0' }, + ]), + ).toEqual([ + { step: 'Go to A > B
', result: 'B page loads' }, + { step: 'Click Save', result: 'Count > 0
' }, + ]); + }); + + it('returns an empty array unchanged', () => { + expect(wrapTestCaseSteps([])).toEqual([]); + }); +});