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
142 changes: 142 additions & 0 deletions packages/host/tests/unit/marked-sync-test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,31 @@
import { module, test } from 'qunit';

import {
SEARCH_MARKER,
SEPARATOR_MARKER,
REPLACE_MARKER,
} from '@cardstack/runtime-common';
import { escapeHtmlOutsideCodeBlocks } from '@cardstack/runtime-common/helpers/html';
import {
markedSync,
markdownToHtml,
} from '@cardstack/runtime-common/marked-sync';

import { parseHtmlContent } from '@cardstack/host/lib/formatted-message/utils';

// The render path a bot message's code patch travels before the host applies
// it: markdown body β†’ bodyHTML (message.ts) β†’ parseHtmlContent β†’
// codeData.searchReplaceBlock. The patch must survive this byte-identical β€”
// the applier matches it against the target file.
function roundTripSearchReplaceBlock(body: string) {
let html = markdownToHtml(escapeHtmlOutsideCodeBlocks(body), {
sanitize: false,
escapeHtmlInCodeBlocks: true,
});
let parts = parseHtmlContent(html, 'room-1', 'event-1');
return parts.find((p) => p.type === 'pre_tag')?.codeData ?? null;
}

module('Unit | marked-sync', function () {
test('markedSync converts markdown to HTML', function (assert) {
const markdown = '# Hello\n**Bold text**';
Expand Down Expand Up @@ -344,6 +365,127 @@ module('Unit | marked-sync', function () {
);
});

test('markdownToHtml prefixes decorative bullets with a list marker', function (assert) {
const markdown = '🌟 First point\n🌟 Second point';
const result = markdownToHtml(markdown);

assert.true(
result.includes('<li>🌟 First point</li>'),
'emoji-led lines become list items',
);
assert.true(
result.includes('<li>🌟 Second point</li>'),
'each emoji-led line is its own list item',
);
});

test('markdownToHtml leaves fenced code block content verbatim when lines start with decorative bullets', function (assert) {
const markdown = [
'🌟 A real list item',
'```gts',
" <span class='marquee-text'>",
' 🚧 SITE UNDER CONSTRUCTION 🚧',
' </span>',
'```',
].join('\n');
const result = markdownToHtml(markdown, { sanitize: false });

assert.true(
result.includes('<li>🌟 A real list item</li>'),
'bullet normalization still applies outside the fence',
);
assert.true(
result.includes(' 🚧 SITE UNDER CONSTRUCTION 🚧'),
'emoji-led line inside the fence is unchanged',
);
assert.false(
result.includes('* 🚧'),
'no list marker is inserted inside the fence',
);
});
Comment on lines +382 to +405

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code πŸ€–] This test asserts the shape of the rendered HTML, one step away from the contract that actually broke β€” and both blocking findings in this review pass it.

The contract is a round trip: a patch the model puts inside a fence has to come back out of extractCodeData byte-identical. Asserting substrings of markdownToHtml's output can hold while the round trip fails, and here it does β€” neither the CRLF case nor the list-nested-fence case is visible to result.includes(' 🚧 …') / assert.false(result.includes('* 🚧')), because both change the input shape, not the assertions' subject.

The test that would have caught them. Render a real SEARCH/REPLACE block through markdownToHtml, push the result through parseHtmlContent (packages/host/app/lib/formatted-message/utils.ts), and assert codeData.searchReplaceBlock equals the source text β€” then parameterize it over the shapes: LF and CRLF, fence at top level and nested in a list item, bare ``` and ```gts openers. Use the real markers from @cardstack/runtime-common (SEARCH_MARKER / SEPARATOR_MARKER / REPLACE_MARKER) rather than emoji-only prose, so the test reads as the scenario it defends.

packages/host/tests/integration/components/formatted-aibot-message-test.gts already has the rendering harness for this, and packages/host/tests/unit/code-patching-test.ts covers parseSearchReplace in isolation. Nothing covers the seam between them, which is precisely where this bug lives β€” a renderer-side rewrite that only manifests as a parser-side mismatch.

On the first new test (prefixes decorative bullets with a list marker): the same behavior is already asserted at the integration level in packages/host/tests/integration/components/ai-assistant-panel/general-test.gts ("it converts decorative star bullets into markdown lists" and the multi-bullet variant). Keeping a unit-level pin is fine and cheaper to run β€” just noting it isn't new ground, so the round-trip test is where the marginal coverage is.

Scope: test coverage. Non-blocking on its own, but it is what turns the two blocking threads into something a future edit can't silently undo.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code πŸ€–] Added in 8341f99: a round-trip test now renders a fenced search/replace block through the same pipeline as message bodyHTML and re-extracts it with parseHtmlContent, asserting byte-identity with what was authored β€” plus a CRLF variant. Both blocking findings fail this test on the previous commit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code πŸ€–] Resolved, and it is the right test β€” it asserts the contract rather than the rendered shape, and it drives the real pipeline (escapeHtmlOutsideCodeBlocks β†’ markdownToHtml β†’ parseHtmlContent β†’ codeData.searchReplaceBlock) instead of a stand-in for it. Using the real SEARCH_MARKER / SEPARATOR_MARKER / REPLACE_MARKER constants means it reads as the scenario it defends, and roundTripSearchReplaceBlock is reusable for the next shape that needs pinning.

I confirmed the claim that it fails on the previous commit: both the CRLF input and the list-nested fence produce a searchReplaceBlock containing * 🚧 there, so strictEqual against the authored block fails on each.

Two shapes from my earlier list are still unparameterized β€” a fence nested in a list item, and a bare ``` opener β€” both of which are round-trippable through the same helper and both of which were failing a commit ago. They are covered indirectly by the HTML-shape test, which is weaker for exactly the reason this thread was about. Non-blocking; roundTripSearchReplaceBlock already makes them one case each.

Separately: the nested-decorative-bullet regression described in the new thread is invisible to every test in this file, which is the same gap in a different place β€” the suite pins what the normalization skips but not what it must still convert.


test('a code patch round-trips byte-identical through render and extraction', function (assert) {
const search = [
" <div class='marquee-wrap'>",
' 🚧 SITE UNDER CONSTRUCTION 🚧 &nbsp;&nbsp;&nbsp; BEST VIEWED IN',
' </div>',
].join('\n');
const block = `${SEARCH_MARKER}\n${search}\n${SEPARATOR_MARKER}\nreplaced\n${REPLACE_MARKER}`;
const body = `Fixing the file now.\n\n\`\`\`gts\nhttps://example.test/hello-world.gts\n${block}\n\`\`\``;

const codeData = roundTripSearchReplaceBlock(body);
assert.ok(codeData, 'a code block was extracted');
assert.strictEqual(
codeData!.searchReplaceBlock,
block,
'the extracted patch is byte-identical to what the bot authored',
);
});

test('a code patch round-trips intact from CRLF input', function (assert) {
const search = ' 🚧 SITE UNDER CONSTRUCTION 🚧';
const block = `${SEARCH_MARKER}\n${search}\n${SEPARATOR_MARKER}\nreplaced\n${REPLACE_MARKER}`;
const body =
`Fixing the file now.\n\n\`\`\`gts\nhttps://example.test/hello-world.gts\n${block}\n\`\`\``.replace(
/\n/g,
'\r\n',
);

const codeData = roundTripSearchReplaceBlock(body);
assert.ok(codeData, 'a code block was extracted from CRLF input');
assert.ok(
codeData!.searchReplaceBlock?.includes(search),
'the emoji-led search line survives unmutated',
);
assert.false(
(codeData!.searchReplaceBlock ?? '').includes('* 🚧'),
'no list marker was inserted into the CRLF fenced content',
);
});

test('a fence opened on a list-item line is respected and does not invert fence tracking', function (assert) {
const markdown = [
'- ```gts',
' 🚧 SITE UNDER CONSTRUCTION 🚧',
' ```',
'',
'🌟 after the list',
].join('\n');
const result = markdownToHtml(markdown, { sanitize: false });

assert.false(
result.includes('* 🚧'),
'content of the list-nested fence is unchanged',
);
assert.true(
result.includes('<li>🌟 after the list</li>'),
'normalization still applies after the fence closes (state did not invert)',
);
});

test('a decorative bullet alone on its line still becomes a list item', function (assert) {
const result = markdownToHtml('🌟\n\ntext after', { sanitize: false });

assert.true(
result.includes('<li>🌟</li>'),
'a bare decorative bullet line is normalized',
);
});

test('a decorative bullet indented four spaces under a list item renders as a nested list', function (assert) {
const markdown = '- item one\n 🌟 nested point';
const result = markdownToHtml(markdown, { sanitize: false });

assert.true(
result.includes('<li>🌟 nested point</li>'),
'the indented decorative bullet becomes its own list item',
);
assert.true(
/<li>item one[\s\S]*<ul>[\s\S]*🌟 nested point/.test(result),
'the decorative bullet nests as a sub-list under the parent item',
);
});

test('markdownToHtml preserves heading IDs through sanitization', function (assert) {
const markdown = '## Test Heading';
const result = markdownToHtml(markdown);
Expand Down
70 changes: 64 additions & 6 deletions packages/runtime-common/marked-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,71 @@ bfmMarked.use({
},
});

// The trailing `\s+|\r?$` alternation keeps a bullet that ends its line β€”
// with or without trailing text β€” normalizable; split('\n') has already
// consumed the newline the old whole-string pattern used to match.
const DECORATIVE_BULLET_PATTERN =
// eslint-disable-next-line no-misleading-character-class -- match pictographic symbols plus a few geometric glyphs not covered by the Unicode class
/(^|\n)(\s*)([\p{Extended_Pictographic}β˜…β€’β–ͺβ—β–βœ¦βœ§β—‰β—¦β—Ύβ—½β¬’β¬‘β˜‘βœ”β˜‘οΈβž€βž”βžœβž‘β†’])(\s+)/gu;
/^(\s*)([\p{Extended_Pictographic}β˜…β€’β–ͺβ—β–βœ¦βœ§β—‰β—¦β—Ύβ—½β¬’β¬‘β˜‘βœ”β˜‘οΈβž€βž”βžœβž‘β†’])(\s+|\r?$)/u;

// `.` never matches `\r`, so on CRLF input the greedy group stops before a
// trailing `\r`; the explicit `\r?` lets `$` still anchor. Without it no
// fence line would ever match CRLF content and the tracker would rewrite
// fenced code.
const CODE_FENCE_PATTERN = /^(\s*)(`{3,}|~{3,})(.*)\r?$/;
// Markdown can open a fence on the same line as a list marker (`- ```gts`);
// marked treats that as fenced code, so the tracker must too. Only openers
// take this form β€” a closing fence cannot carry an info string, let alone a
// list marker.
const LIST_PREFIXED_CODE_FENCE_PATTERN =
/^(\s*)(?:[-*+]|\d{1,9}[.)])\s+(`{3,}|~{3,})(.*)\r?$/;
// Prefix decorative bullets with a standard list marker so marked treats them
// as list items β€” but never inside fenced code blocks. Fenced content must
// survive rendering verbatim: search/replace patches are extracted back out
// of the rendered HTML, and an inserted marker makes the patch text no longer
// match the file it targets.
//
// Only *fenced* blocks are protected. A 4-space indented line is an indented
// code block to marked in some contexts, but inside a list item the same
// indentation is ordinary list content (a nested bullet); telling the two
// apart needs block context that only the lexer has. Since the patch format
// is always fenced, indented code blocks are left to the rewrite.
function normalizeDecorativeBullets(markdown: string): string {
let inFence = false;
let fenceChar = '';
let fenceLength = 0;
return markdown
.split('\n')
.map((line) => {
if (inFence) {
let closeMatch = line.match(CODE_FENCE_PATTERN);
if (
closeMatch &&
closeMatch[2][0] === fenceChar &&
closeMatch[2].length >= fenceLength &&
closeMatch[3].trim() === ''
) {
inFence = false;
}
return line;
}
let openMatch =
line.match(CODE_FENCE_PATTERN) ??
line.match(LIST_PREFIXED_CODE_FENCE_PATTERN);
if (openMatch) {
inFence = true;
fenceChar = openMatch[2][0];
fenceLength = openMatch[2].length;
return line;
}
return line.replace(
DECORATIVE_BULLET_PATTERN,
(_match, indentation, bullet, whitespace) =>
`${indentation}* ${bullet}${whitespace}`,
);
})
.join('\n');
}

const DEFAULT_MARKED_SYNC_OPTIONS = {
escapeHtmlInCodeBlocks: true,
Expand Down Expand Up @@ -187,11 +249,7 @@ export function markdownToHtml(
return '';
}
// Marked only treats ASCII list markers, so prefix decorative bullets with a standard marker.
let normalizedMarkdown = markdown.replace(
DECORATIVE_BULLET_PATTERN,
(_match, boundary, indentation, bullet, whitespace) =>
`${boundary}${indentation}* ${bullet}${whitespace}`,
);
let normalizedMarkdown = normalizeDecorativeBullets(markdown);
let html = markedSync(normalizedMarkdown, {
escapeHtmlInCodeBlocks: opts.escapeHtmlInCodeBlocks,
enableMonacoSyntaxHighlighting: opts.enableMonacoSyntaxHighlighting,
Expand Down
Loading