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 wrapTestCaseSteps( + steps: T[], +): T[] { + return steps.map((s) => ({ + ...s, + step: wrapRichText(s.step), + result: wrapRichText(s.result), + })); +} diff --git a/src/tools/testmanagement-utils/update-testcase.ts b/src/tools/testmanagement-utils/update-testcase.ts index c214555..0b7a1cb 100644 --- a/src/tools/testmanagement-utils/update-testcase.ts +++ b/src/tools/testmanagement-utils/update-testcase.ts @@ -10,6 +10,7 @@ import { import { BrowserStackConfig } from "../../lib/types.js"; import { getTMBaseURL } from "../../lib/tm-base-url.js"; import { getBrowserStackAuth } from "../../lib/get-auth.js"; +import { wrapRichText, wrapTestCaseSteps } from "./rich-text.js"; import logger from "../../logger.js"; export interface TestCaseUpdateRequest { @@ -178,11 +179,11 @@ export async function updateTestCase( if (params.name !== undefined) testCaseBody.name = params.name; if (params.description !== undefined) - testCaseBody.description = params.description; + testCaseBody.description = wrapRichText(params.description); if (params.preconditions !== undefined) - testCaseBody.preconditions = params.preconditions; + testCaseBody.preconditions = wrapRichText(params.preconditions); if (params.test_case_steps !== undefined) - testCaseBody.test_case_steps = params.test_case_steps; + testCaseBody.test_case_steps = wrapTestCaseSteps(params.test_case_steps); if (params.owner !== undefined) testCaseBody.owner = params.owner; if (params.status !== undefined) testCaseBody.status = params.status; if (params.tags !== undefined) testCaseBody.tags = params.tags; diff --git a/tests/tools/richText.test.ts b/tests/tools/richText.test.ts new file mode 100644 index 0000000..798b367 --- /dev/null +++ b/tests/tools/richText.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + wrapRichText, + wrapTestCaseSteps, +} from '../../src/tools/testmanagement-utils/rich-text'; + +describe('wrapRichText', () => { + it('escapes and wraps bare text containing >', () => { + expect(wrapRichText('Navigate to Settings > Users')).toBe( + '

Navigate 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('Press to submit the form')).toBe( + '

Press <Enter> to submit the form

', + ); + expect(wrapRichText('Use List where A > B & C')).toBe( + '

Use List<String> where A > B & C

', + ); + // Leading tag-like token that is NOT a TM-allowed tag is literal text too. + expect(wrapRichText(' key submits the form')).toBe( + '

<Enter> key submits the form

', + ); + expect(wrapRichText('

not an allowed TM tag

')).toBe( + '

<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('
  • A & B
')).toBe( + '
  • A & B
', + ); + expect(wrapRichText('
')).toBe('
'); + }); + + it('treats a leading < that is not a tag as literal text', () => { + expect(wrapRichText('< 5 items are shown')).toBe( + '

< 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([]); + }); +});