Skip to content

Commit 7029c27

Browse files
SavioBS629claude
andauthored
fix: wrap rich-text fields in <p> so TM renders special characters correctly (#356)
* fix(testmanagement): wrap rich-text fields in <p> so TM renders special chars 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 "&gt;" (PMAA-185, FD 1709260, TM-26634). Per the TM team, external API content must be wrapped in <p> 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 <noreply@anthropic.com> * refactor(testmanagement): trim rich-text comments, add generality tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 "<Enter>" or "List<String>". TM's own Lexical editor and import paths escape text then wrap in <p>; 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 <noreply@anthropic.com> * fix: removed comment --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 2e1121a commit 7029c27

4 files changed

Lines changed: 139 additions & 3 deletions

File tree

src/tools/testmanagement-utils/create-testcase.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from "./TCG-utils/api.js";
1010
import { BrowserStackConfig } from "../../lib/types.js";
1111
import { getTMBaseURL } from "../../lib/tm-base-url.js";
12+
import { wrapRichText, wrapTestCaseSteps } from "./rich-text.js";
1213
import logger from "../../logger.js";
1314

1415
interface TestCaseStep {
@@ -324,6 +325,16 @@ export async function createTestCase(
324325
new Set([...(testCaseParams.tags ?? []), "MCP Generated"]),
325326
);
326327

328+
testCaseParams.test_case_steps = wrapTestCaseSteps(
329+
testCaseParams.test_case_steps,
330+
);
331+
if (testCaseParams.preconditions !== undefined) {
332+
testCaseParams.preconditions = wrapRichText(testCaseParams.preconditions);
333+
}
334+
if (testCaseParams.description !== undefined) {
335+
testCaseParams.description = wrapRichText(testCaseParams.description);
336+
}
337+
327338
if (
328339
testCaseParams.priority !== undefined ||
329340
testCaseParams.case_type !== undefined
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// TM renders these fields as rich text only when the value is HTML;
2+
const TM_ALLOWED_TAG =
3+
/^\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;
4+
const HAS_ENCODABLE_CHARS = /[<>&]/;
5+
6+
function escapeHtml(text: string): string {
7+
return text
8+
.replace(/&/g, "&amp;")
9+
.replace(/</g, "&lt;")
10+
.replace(/>/g, "&gt;");
11+
}
12+
13+
export function wrapRichText(text: string): string {
14+
if (TM_ALLOWED_TAG.test(text) || !HAS_ENCODABLE_CHARS.test(text)) {
15+
return text;
16+
}
17+
return `<p>${escapeHtml(text)}</p>`;
18+
}
19+
20+
export function wrapTestCaseSteps<T extends { step: string; result: string }>(
21+
steps: T[],
22+
): T[] {
23+
return steps.map((s) => ({
24+
...s,
25+
step: wrapRichText(s.step),
26+
result: wrapRichText(s.result),
27+
}));
28+
}

src/tools/testmanagement-utils/update-testcase.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { BrowserStackConfig } from "../../lib/types.js";
1111
import { getTMBaseURL } from "../../lib/tm-base-url.js";
1212
import { getBrowserStackAuth } from "../../lib/get-auth.js";
13+
import { wrapRichText, wrapTestCaseSteps } from "./rich-text.js";
1314
import logger from "../../logger.js";
1415

1516
export interface TestCaseUpdateRequest {
@@ -178,11 +179,11 @@ export async function updateTestCase(
178179

179180
if (params.name !== undefined) testCaseBody.name = params.name;
180181
if (params.description !== undefined)
181-
testCaseBody.description = params.description;
182+
testCaseBody.description = wrapRichText(params.description);
182183
if (params.preconditions !== undefined)
183-
testCaseBody.preconditions = params.preconditions;
184+
testCaseBody.preconditions = wrapRichText(params.preconditions);
184185
if (params.test_case_steps !== undefined)
185-
testCaseBody.test_case_steps = params.test_case_steps;
186+
testCaseBody.test_case_steps = wrapTestCaseSteps(params.test_case_steps);
186187
if (params.owner !== undefined) testCaseBody.owner = params.owner;
187188
if (params.status !== undefined) testCaseBody.status = params.status;
188189
if (params.tags !== undefined) testCaseBody.tags = params.tags;

tests/tools/richText.test.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
wrapRichText,
4+
wrapTestCaseSteps,
5+
} from '../../src/tools/testmanagement-utils/rich-text';
6+
7+
describe('wrapRichText', () => {
8+
it('escapes and wraps bare text containing >', () => {
9+
expect(wrapRichText('Navigate to Settings > Users')).toBe(
10+
'<p>Navigate to Settings &gt; Users</p>',
11+
);
12+
});
13+
14+
it('escapes and wraps bare text containing <', () => {
15+
expect(wrapRichText('if a < b then pass')).toBe(
16+
'<p>if a &lt; b then pass</p>',
17+
);
18+
});
19+
20+
it('escapes and wraps bare text containing &', () => {
21+
expect(wrapRichText('Q&A section loads')).toBe(
22+
'<p>Q&amp;A section loads</p>',
23+
);
24+
});
25+
26+
it('preserves tag-like tokens that TM would otherwise strip', () => {
27+
expect(wrapRichText('Press <Enter> to submit the form')).toBe(
28+
'<p>Press &lt;Enter&gt; to submit the form</p>',
29+
);
30+
expect(wrapRichText('Use List<String> where A > B & C')).toBe(
31+
'<p>Use List&lt;String&gt; where A &gt; B &amp; C</p>',
32+
);
33+
// Leading tag-like token that is NOT a TM-allowed tag is literal text too.
34+
expect(wrapRichText('<Enter> key submits the form')).toBe(
35+
'<p>&lt;Enter&gt; key submits the form</p>',
36+
);
37+
expect(wrapRichText('<h1>not an allowed TM tag</h1>')).toBe(
38+
'<p>&lt;h1&gt;not an allowed TM tag&lt;/h1&gt;</p>',
39+
);
40+
});
41+
42+
it('leaves text without <, > or & untouched', () => {
43+
expect(wrapRichText('Click "Save" then \'Confirm\'')).toBe(
44+
'Click "Save" then \'Confirm\'',
45+
);
46+
});
47+
48+
it('leaves content starting with a TM-allowed tag untouched', () => {
49+
expect(wrapRichText('<p>Settings &gt; Users</p>')).toBe(
50+
'<p>Settings &gt; Users</p>',
51+
);
52+
expect(wrapRichText(' <ul><li>A &amp; B</li></ul>')).toBe(
53+
' <ul><li>A &amp; B</li></ul>',
54+
);
55+
expect(wrapRichText('<br/>')).toBe('<br/>');
56+
});
57+
58+
it('treats a leading < that is not a tag as literal text', () => {
59+
expect(wrapRichText('< 5 items are shown')).toBe(
60+
'<p>&lt; 5 items are shown</p>',
61+
);
62+
});
63+
64+
it('handles any characters, not just fixed strings', () => {
65+
expect(wrapRichText('émojis 🎉 and ünïcode ≥ 5 stay bare')).toBe(
66+
'émojis 🎉 and ünïcode ≥ 5 stay bare',
67+
);
68+
expect(wrapRichText('unicode plus html char: 温度 > 30°C')).toBe(
69+
'<p>unicode plus html char: 温度 &gt; 30°C</p>',
70+
);
71+
expect(wrapRichText('line1 a > b\nline2 c < d')).toBe(
72+
'<p>line1 a &gt; b\nline2 c &lt; d</p>',
73+
);
74+
expect(wrapRichText('a>=b && c<=d & "e"')).toBe(
75+
'<p>a&gt;=b &amp;&amp; c&lt;=d &amp; "e"</p>',
76+
);
77+
});
78+
});
79+
80+
describe('wrapTestCaseSteps', () => {
81+
it('wraps step and result independently and preserves other keys', () => {
82+
expect(
83+
wrapTestCaseSteps([
84+
{ step: 'Go to A > B', result: 'B page loads' },
85+
{ step: 'Click Save', result: 'Count > 0' },
86+
]),
87+
).toEqual([
88+
{ step: '<p>Go to A &gt; B</p>', result: 'B page loads' },
89+
{ step: 'Click Save', result: '<p>Count &gt; 0</p>' },
90+
]);
91+
});
92+
93+
it('returns an empty array unchanged', () => {
94+
expect(wrapTestCaseSteps([])).toEqual([]);
95+
});
96+
});

0 commit comments

Comments
 (0)