From 82a3be2cf410c02d79c615458cb74b29e2213dd2 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 29 Jul 2026 12:20:41 +0530 Subject: [PATCH 1/4] fix(testmanagement): wrap rich-text fields in

so TM renders special chars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TM's v2 API entity-encodes <, > and & in step/result, preconditions and description on write, and the TM UI renders those fields as rich text only when the value is HTML-wrapped — bare text is displayed literally, so a plain ">" surfaced to users as ">" (PMAA-185, FD 1709260, TM-26634). Per the TM team, external API content must be wrapped in

tags. Wrap those fields on createTestCase and updateTestCase when the value is not already HTML and contains an encodable character; all other payloads are sent unchanged. Co-Authored-By: Claude Fable 5 --- .../testmanagement-utils/create-testcase.ts | 13 ++++ src/tools/testmanagement-utils/rich-text.ts | 28 +++++++++ .../testmanagement-utils/update-testcase.ts | 9 ++- tests/tools/richText.test.ts | 62 +++++++++++++++++++ 4 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 src/tools/testmanagement-utils/rich-text.ts create mode 100644 tests/tools/richText.test.ts diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index 446e2fe..c6a435e 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,18 @@ export async function createTestCase( new Set([...(testCaseParams.tags ?? []), "MCP Generated"]), ); + // Rich-text fields must be HTML-wrapped or the TM UI shows entity-encoded + // text literally (PMAA-185); see rich-text.ts. + 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..0e4f0d0 --- /dev/null +++ b/src/tools/testmanagement-utils/rich-text.ts @@ -0,0 +1,28 @@ +// TM's v2 API treats step/result, preconditions and description as rich-text +// fields: its write-time sanitizer entity-encodes <, > and & in text nodes +// (preserving allowed tags like

), and the TM UI renders the stored value +// as rich text only when it is HTML-wrapped — bare text is displayed +// literally, so a bare ">" surfaces to users as ">" (PMAA-185). +// Per the TM team, external API content must be wrapped in

tags to opt +// into rich-text rendering. Quotes are not entity-encoded, so only values +// containing <, > or & need the wrap; everything else is left untouched. + +const LOOKS_LIKE_HTML = /^\s*<[a-z][^>]*>/i; +const HAS_ENCODABLE_CHARS = /[<>&]/; + +export function wrapRichText(text: string): string { + if (LOOKS_LIKE_HTML.test(text) || !HAS_ENCODABLE_CHARS.test(text)) { + return text; + } + return `

${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..9628240 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 { @@ -177,12 +178,14 @@ export async function updateTestCase( const testCaseBody: Record = {}; if (params.name !== undefined) testCaseBody.name = params.name; + // Rich-text fields must be HTML-wrapped or the TM UI shows entity-encoded + // text literally (PMAA-185); see rich-text.ts. 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..8e38b39 --- /dev/null +++ b/tests/tools/richText.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { + wrapRichText, + wrapTestCaseSteps, +} from '../../src/tools/testmanagement-utils/rich-text'; + +describe('wrapRichText', () => { + it('wraps bare text containing > in

tags', () => { + expect(wrapRichText('Navigate to Settings > Users')).toBe( + '

Navigate to Settings > Users

', + ); + }); + + it('wraps bare text containing < in

tags', () => { + expect(wrapRichText('if a < b then pass')).toBe( + '

if a < b then pass

', + ); + }); + + it('wraps bare text containing & in

tags', () => { + expect(wrapRichText('Q&A section loads')).toBe('

Q&A section loads

'); + }); + + it('leaves text without <, > or & untouched', () => { + expect(wrapRichText('Click "Save" then \'Confirm\'')).toBe( + 'Click "Save" then \'Confirm\'', + ); + }); + + it('leaves already-HTML content untouched', () => { + expect(wrapRichText('

Settings > Users

')).toBe( + '

Settings > Users

', + ); + expect(wrapRichText('
  • A & B
')).toBe( + '
  • A & B
', + ); + }); + + it('wraps text starting with < that is not a tag', () => { + expect(wrapRichText('< 5 items are shown')).toBe( + '

< 5 items are shown

', + ); + }); +}); + +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([]); + }); +}); From cc3a498a3d8b2ac7451de43ed7224fa705c2f6c2 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 29 Jul 2026 14:13:18 +0530 Subject: [PATCH 2/4] refactor(testmanagement): trim rich-text comments, add generality tests Co-Authored-By: Claude Fable 5 --- src/tools/testmanagement-utils/create-testcase.ts | 2 -- src/tools/testmanagement-utils/rich-text.ts | 10 +--------- src/tools/testmanagement-utils/update-testcase.ts | 2 -- tests/tools/richText.test.ts | 15 +++++++++++++++ 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/tools/testmanagement-utils/create-testcase.ts b/src/tools/testmanagement-utils/create-testcase.ts index c6a435e..c87a2c0 100644 --- a/src/tools/testmanagement-utils/create-testcase.ts +++ b/src/tools/testmanagement-utils/create-testcase.ts @@ -325,8 +325,6 @@ export async function createTestCase( new Set([...(testCaseParams.tags ?? []), "MCP Generated"]), ); - // Rich-text fields must be HTML-wrapped or the TM UI shows entity-encoded - // text literally (PMAA-185); see rich-text.ts. testCaseParams.test_case_steps = wrapTestCaseSteps( testCaseParams.test_case_steps, ); diff --git a/src/tools/testmanagement-utils/rich-text.ts b/src/tools/testmanagement-utils/rich-text.ts index 0e4f0d0..9a9b634 100644 --- a/src/tools/testmanagement-utils/rich-text.ts +++ b/src/tools/testmanagement-utils/rich-text.ts @@ -1,12 +1,4 @@ -// TM's v2 API treats step/result, preconditions and description as rich-text -// fields: its write-time sanitizer entity-encodes <, > and & in text nodes -// (preserving allowed tags like

), and the TM UI renders the stored value -// as rich text only when it is HTML-wrapped — bare text is displayed -// literally, so a bare ">" surfaces to users as ">" (PMAA-185). -// Per the TM team, external API content must be wrapped in

tags to opt -// into rich-text rendering. Quotes are not entity-encoded, so only values -// containing <, > or & need the wrap; everything else is left untouched. - +// TM UI renders these fields as rich text only when HTML-wrapped; const LOOKS_LIKE_HTML = /^\s*<[a-z][^>]*>/i; const HAS_ENCODABLE_CHARS = /[<>&]/; diff --git a/src/tools/testmanagement-utils/update-testcase.ts b/src/tools/testmanagement-utils/update-testcase.ts index 9628240..0b7a1cb 100644 --- a/src/tools/testmanagement-utils/update-testcase.ts +++ b/src/tools/testmanagement-utils/update-testcase.ts @@ -178,8 +178,6 @@ export async function updateTestCase( const testCaseBody: Record = {}; if (params.name !== undefined) testCaseBody.name = params.name; - // Rich-text fields must be HTML-wrapped or the TM UI shows entity-encoded - // text literally (PMAA-185); see rich-text.ts. if (params.description !== undefined) testCaseBody.description = wrapRichText(params.description); if (params.preconditions !== undefined) diff --git a/tests/tools/richText.test.ts b/tests/tools/richText.test.ts index 8e38b39..f93a22f 100644 --- a/tests/tools/richText.test.ts +++ b/tests/tools/richText.test.ts @@ -41,6 +41,21 @@ describe('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', () => { From aca16d44a4e0b817ff62d7bdede81b49d0439a11 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 29 Jul 2026 17:22:12 +0530 Subject: [PATCH 3/4] fix(testmanagement): escape rich text before p-wrapping, mirror TM's own editor TM's sanitizer (Sanitize.fragment with RTE_ALLOWED_CONTENT) strips unknown tag-like tokens inside HTML values, so wrapping unescaped text lost content like "" or "List". TM's own Lexical editor and import paths escape text then wrap in

; do the same, and pass through only values that start with a tag from TM's allowed-elements list. Co-Authored-By: Claude Fable 5 --- src/tools/testmanagement-utils/rich-text.ts | 20 +++++++-- tests/tools/richText.test.ts | 47 +++++++++++++++------ 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/tools/testmanagement-utils/rich-text.ts b/src/tools/testmanagement-utils/rich-text.ts index 9a9b634..0e35bb1 100644 --- a/src/tools/testmanagement-utils/rich-text.ts +++ b/src/tools/testmanagement-utils/rich-text.ts @@ -1,12 +1,24 @@ -// TM UI renders these fields as rich text only when HTML-wrapped; -const LOOKS_LIKE_HTML = /^\s*<[a-z][^>]*>/i; +// TM renders these fields as rich text only when the value is HTML; bare text +// is displayed with <, > and & entity-encoded, and unknown tag-like tokens +// (e.g. "") are stripped by TM's sanitizer. Mirror what TM's own editor +// and import paths do: escape the text, then wrap it in

. Values that +// already start with a TM-allowed tag pass through as intentional 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 (LOOKS_LIKE_HTML.test(text) || !HAS_ENCODABLE_CHARS.test(text)) { + if (TM_ALLOWED_TAG.test(text) || !HAS_ENCODABLE_CHARS.test(text)) { return text; } - return `

${text}

`; + return `

${escapeHtml(text)}

`; } export function wrapTestCaseSteps( diff --git a/tests/tools/richText.test.ts b/tests/tools/richText.test.ts index f93a22f..798b367 100644 --- a/tests/tools/richText.test.ts +++ b/tests/tools/richText.test.ts @@ -5,20 +5,38 @@ import { } from '../../src/tools/testmanagement-utils/rich-text'; describe('wrapRichText', () => { - it('wraps bare text containing > in

tags', () => { + it('escapes and wraps bare text containing >', () => { expect(wrapRichText('Navigate to Settings > Users')).toBe( - '

Navigate to Settings > Users

', + '

Navigate to Settings > Users

', ); }); - it('wraps bare text containing < in

tags', () => { + it('escapes and wraps bare text containing <', () => { expect(wrapRichText('if a < b then pass')).toBe( - '

if a < b then pass

', + '

if a < b then pass

', ); }); - it('wraps bare text containing & in

tags', () => { - expect(wrapRichText('Q&A section loads')).toBe('

Q&A section loads

'); + 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', () => { @@ -27,18 +45,19 @@ describe('wrapRichText', () => { ); }); - it('leaves already-HTML content untouched', () => { + 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('wraps text starting with < that is not a tag', () => { + it('treats a leading < that is not a tag as literal text', () => { expect(wrapRichText('< 5 items are shown')).toBe( - '

< 5 items are shown

', + '

< 5 items are shown

', ); }); @@ -47,13 +66,13 @@ describe('wrapRichText', () => { 'émojis 🎉 and ünïcode ≥ 5 stay bare', ); expect(wrapRichText('unicode plus html char: 温度 > 30°C')).toBe( - '

unicode plus html char: 温度 > 30°C

', + '

unicode plus html char: 温度 > 30°C

', ); expect(wrapRichText('line1 a > b\nline2 c < d')).toBe( - '

line1 a > b\nline2 c < d

', + '

line1 a > b\nline2 c < d

', ); expect(wrapRichText('a>=b && c<=d & "e"')).toBe( - '

a>=b && c<=d & "e"

', + '

a>=b && c<=d & "e"

', ); }); }); @@ -66,8 +85,8 @@ describe('wrapTestCaseSteps', () => { { step: 'Click Save', result: 'Count > 0' }, ]), ).toEqual([ - { step: '

Go to A > B

', result: 'B page loads' }, - { step: 'Click Save', result: '

Count > 0

' }, + { step: '

Go to A > B

', result: 'B page loads' }, + { step: 'Click Save', result: '

Count > 0

' }, ]); }); From e3a13324162f2c4329b67a21a5086ebf867e6a52 Mon Sep 17 00:00:00 2001 From: Savio Dias Date: Wed, 29 Jul 2026 17:50:53 +0530 Subject: [PATCH 4/4] fix: removed comment --- src/tools/testmanagement-utils/rich-text.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/tools/testmanagement-utils/rich-text.ts b/src/tools/testmanagement-utils/rich-text.ts index 0e35bb1..4b926b0 100644 --- a/src/tools/testmanagement-utils/rich-text.ts +++ b/src/tools/testmanagement-utils/rich-text.ts @@ -1,8 +1,4 @@ -// TM renders these fields as rich text only when the value is HTML; bare text -// is displayed with <, > and & entity-encoded, and unknown tag-like tokens -// (e.g. "") are stripped by TM's sanitizer. Mirror what TM's own editor -// and import paths do: escape the text, then wrap it in

. Values that -// already start with a TM-allowed tag pass through as intentional HTML. +// 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 = /[<>&]/;