co-mono/packages/tui/test/markdown.test.ts
Michael Renner 620239bd3f
fix(tui): prevent duplicate URL display for autolinked emails (#888)
Autolinked emails like user@example.com were rendered as
'user@example.com (mailto:user@example.com)' because the comparison
token.text === token.href failed (text lacks mailto: prefix).

Now strips mailto: prefix before comparing, so autolinked emails
display without redundant URL in parentheses.
2026-01-21 23:20:00 +01:00

733 lines
24 KiB
TypeScript

import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { Chalk } from "chalk";
import { Markdown } from "../src/components/markdown.js";
import { type Component, TUI } from "../src/tui.js";
import { defaultMarkdownTheme } from "./test-themes.js";
import { VirtualTerminal } from "./virtual-terminal.js";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
describe("Markdown component", () => {
describe("Nested lists", () => {
it("should render simple nested list", () => {
const markdown = new Markdown(
`- Item 1
- Nested 1.1
- Nested 1.2
- Item 2`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
// Check that we have content
assert.ok(lines.length > 0);
// Strip ANSI codes for checking
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Check structure
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.1")));
assert.ok(plainLines.some((line) => line.includes(" - Nested 1.2")));
assert.ok(plainLines.some((line) => line.includes("- Item 2")));
});
it("should render deeply nested list", () => {
const markdown = new Markdown(
`- Level 1
- Level 2
- Level 3
- Level 4`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Check proper indentation
assert.ok(plainLines.some((line) => line.includes("- Level 1")));
assert.ok(plainLines.some((line) => line.includes(" - Level 2")));
assert.ok(plainLines.some((line) => line.includes(" - Level 3")));
assert.ok(plainLines.some((line) => line.includes(" - Level 4")));
});
it("should render ordered nested list", () => {
const markdown = new Markdown(
`1. First
1. Nested first
2. Nested second
2. Second`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
assert.ok(plainLines.some((line) => line.includes("1. First")));
assert.ok(plainLines.some((line) => line.includes(" 1. Nested first")));
assert.ok(plainLines.some((line) => line.includes(" 2. Nested second")));
assert.ok(plainLines.some((line) => line.includes("2. Second")));
});
it("should render mixed ordered and unordered nested lists", () => {
const markdown = new Markdown(
`1. Ordered item
- Unordered nested
- Another nested
2. Second ordered
- More nested`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
assert.ok(plainLines.some((line) => line.includes("1. Ordered item")));
assert.ok(plainLines.some((line) => line.includes(" - Unordered nested")));
assert.ok(plainLines.some((line) => line.includes("2. Second ordered")));
});
it("should maintain numbering when code blocks are not indented (LLM output)", () => {
// When code blocks aren't indented, marked parses each item as a separate list.
// We use token.start to preserve the original numbering.
const markdown = new Markdown(
`1. First item
\`\`\`typescript
// code block
\`\`\`
2. Second item
\`\`\`typescript
// another code block
\`\`\`
3. Third item`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trim());
// Find all lines that start with a number and period
const numberedLines = plainLines.filter((line) => /^\d+\./.test(line));
// Should have 3 numbered items
assert.strictEqual(numberedLines.length, 3, `Expected 3 numbered items, got: ${numberedLines.join(", ")}`);
// Check the actual numbers
assert.ok(numberedLines[0].startsWith("1."), `First item should be "1.", got: ${numberedLines[0]}`);
assert.ok(numberedLines[1].startsWith("2."), `Second item should be "2.", got: ${numberedLines[1]}`);
assert.ok(numberedLines[2].startsWith("3."), `Third item should be "3.", got: ${numberedLines[2]}`);
});
});
describe("Tables", () => {
it("should render simple table", () => {
const markdown = new Markdown(
`| Name | Age |
| --- | --- |
| Alice | 30 |
| Bob | 25 |`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Check table structure
assert.ok(plainLines.some((line) => line.includes("Name")));
assert.ok(plainLines.some((line) => line.includes("Age")));
assert.ok(plainLines.some((line) => line.includes("Alice")));
assert.ok(plainLines.some((line) => line.includes("Bob")));
// Check for table borders
assert.ok(plainLines.some((line) => line.includes("│")));
assert.ok(plainLines.some((line) => line.includes("─")));
});
it("should render table with alignment", () => {
const markdown = new Markdown(
`| Left | Center | Right |
| :--- | :---: | ---: |
| A | B | C |
| Long text | Middle | End |`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Check headers
assert.ok(plainLines.some((line) => line.includes("Left")));
assert.ok(plainLines.some((line) => line.includes("Center")));
assert.ok(plainLines.some((line) => line.includes("Right")));
// Check content
assert.ok(plainLines.some((line) => line.includes("Long text")));
});
it("should handle tables with varying column widths", () => {
const markdown = new Markdown(
`| Short | Very long column header |
| --- | --- |
| A | This is a much longer cell content |
| B | Short |`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
// Should render without errors
assert.ok(lines.length > 0);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
assert.ok(plainLines.some((line) => line.includes("Very long column header")));
assert.ok(plainLines.some((line) => line.includes("This is a much longer cell content")));
});
it("should wrap table cells when table exceeds available width", () => {
const markdown = new Markdown(
`| Command | Description | Example |
| --- | --- | --- |
| npm install | Install all dependencies | npm install |
| npm run build | Build the project | npm run build |`,
0,
0,
defaultMarkdownTheme,
);
// Render at narrow width that forces wrapping
const lines = markdown.render(50);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// All lines should fit within width
for (const line of plainLines) {
assert.ok(line.length <= 50, `Line exceeds width 50: "${line}" (length: ${line.length})`);
}
// Content should still be present (possibly wrapped across lines)
const allText = plainLines.join(" ");
assert.ok(allText.includes("Command"), "Should contain 'Command'");
assert.ok(allText.includes("Description"), "Should contain 'Description'");
assert.ok(allText.includes("npm install"), "Should contain 'npm install'");
assert.ok(allText.includes("Install"), "Should contain 'Install'");
});
it("should wrap long cell content to multiple lines", () => {
const markdown = new Markdown(
`| Header |
| --- |
| This is a very long cell content that should wrap |`,
0,
0,
defaultMarkdownTheme,
);
// Render at width that forces the cell to wrap
const lines = markdown.render(25);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Should have multiple data rows due to wrapping
const dataRows = plainLines.filter((line) => line.startsWith("│") && !line.includes("─"));
assert.ok(dataRows.length > 2, `Expected wrapped rows, got ${dataRows.length} rows`);
// All content should be preserved (may be split across lines)
const allText = plainLines.join(" ");
assert.ok(allText.includes("very long"), "Should preserve 'very long'");
assert.ok(allText.includes("cell content"), "Should preserve 'cell content'");
assert.ok(allText.includes("should wrap"), "Should preserve 'should wrap'");
});
it("should wrap long unbroken tokens inside table cells (not only at line start)", () => {
const url = "https://example.com/this/is/a/very/long/url/that/should/wrap";
const markdown = new Markdown(
`| Value |
| --- |
| prefix ${url} |`,
0,
0,
defaultMarkdownTheme,
);
const width = 30;
const lines = markdown.render(width);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
for (const line of plainLines) {
assert.ok(line.length <= width, `Line exceeds width ${width}: "${line}" (length: ${line.length})`);
}
// Borders should stay intact (exactly 2 vertical borders for a 1-col table)
const tableLines = plainLines.filter((line) => line.startsWith("│"));
for (const line of tableLines) {
const borderCount = line.split("│").length - 1;
assert.strictEqual(borderCount, 2, `Expected 2 borders, got ${borderCount}: "${line}"`);
}
// Strip box drawing characters + whitespace so we can assert the URL is preserved
// even if it was split across multiple wrapped lines.
const extracted = plainLines.join("").replace(/[│├┤─\s]/g, "");
assert.ok(extracted.includes("prefix"), "Should preserve 'prefix'");
assert.ok(extracted.includes(url), "Should preserve URL");
});
it("should wrap styled inline code inside table cells without breaking borders", () => {
const markdown = new Markdown(
`| Code |
| --- |
| \`averyveryveryverylongidentifier\` |`,
0,
0,
defaultMarkdownTheme,
);
const width = 20;
const lines = markdown.render(width);
const joinedOutput = lines.join("\n");
assert.ok(joinedOutput.includes("\x1b[33m"), "Inline code should be styled (yellow)");
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
for (const line of plainLines) {
assert.ok(line.length <= width, `Line exceeds width ${width}: "${line}" (length: ${line.length})`);
}
const tableLines = plainLines.filter((line) => line.startsWith("│"));
for (const line of tableLines) {
const borderCount = line.split("│").length - 1;
assert.strictEqual(borderCount, 2, `Expected 2 borders, got ${borderCount}: "${line}"`);
}
});
it("should handle extremely narrow width gracefully", () => {
const markdown = new Markdown(
`| A | B | C |
| --- | --- | --- |
| 1 | 2 | 3 |`,
0,
0,
defaultMarkdownTheme,
);
// Very narrow width
const lines = markdown.render(15);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Should not crash and should produce output
assert.ok(lines.length > 0, "Should produce output");
// Lines should not exceed width
for (const line of plainLines) {
assert.ok(line.length <= 15, `Line exceeds width 15: "${line}" (length: ${line.length})`);
}
});
it("should render table correctly when it fits naturally", () => {
const markdown = new Markdown(
`| A | B |
| --- | --- |
| 1 | 2 |`,
0,
0,
defaultMarkdownTheme,
);
// Wide width where table fits naturally
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// Should have proper table structure
const headerLine = plainLines.find((line) => line.includes("A") && line.includes("B"));
assert.ok(headerLine, "Should have header row");
assert.ok(headerLine?.includes("│"), "Header should have borders");
const separatorLine = plainLines.find((line) => line.includes("├") && line.includes("┼"));
assert.ok(separatorLine, "Should have separator row");
const dataLine = plainLines.find((line) => line.includes("1") && line.includes("2"));
assert.ok(dataLine, "Should have data row");
});
it("should respect paddingX when calculating table width", () => {
const markdown = new Markdown(
`| Column One | Column Two |
| --- | --- |
| Data 1 | Data 2 |`,
2, // paddingX = 2
0,
defaultMarkdownTheme,
);
// Width 40 with paddingX=2 means contentWidth=36
const lines = markdown.render(40);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
// All lines should respect width
for (const line of plainLines) {
assert.ok(line.length <= 40, `Line exceeds width 40: "${line}" (length: ${line.length})`);
}
// Table rows should have left padding
const tableRow = plainLines.find((line) => line.includes("│"));
assert.ok(tableRow?.startsWith(" "), "Table should have left padding");
});
});
describe("Combined features", () => {
it("should render lists and tables together", () => {
const markdown = new Markdown(
`# Test Document
- Item 1
- Nested item
- Item 2
| Col1 | Col2 |
| --- | --- |
| A | B |`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
// Check heading
assert.ok(plainLines.some((line) => line.includes("Test Document")));
// Check list
assert.ok(plainLines.some((line) => line.includes("- Item 1")));
assert.ok(plainLines.some((line) => line.includes(" - Nested item")));
// Check table
assert.ok(plainLines.some((line) => line.includes("Col1")));
assert.ok(plainLines.some((line) => line.includes("│")));
});
});
describe("Pre-styled text (thinking traces)", () => {
it("should preserve gray italic styling after inline code", () => {
// This replicates how thinking content is rendered in assistant-message.ts
const markdown = new Markdown(
"This is thinking with `inline code` and more text after",
1,
0,
defaultMarkdownTheme,
{
color: (text) => chalk.gray(text),
italic: true,
},
);
const lines = markdown.render(80);
const joinedOutput = lines.join("\n");
// Should contain the inline code block
assert.ok(joinedOutput.includes("inline code"));
// The output should have ANSI codes for gray (90) and italic (3)
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
// Verify that inline code is styled (theme uses yellow)
const hasCodeColor = joinedOutput.includes("\x1b[33m");
assert.ok(hasCodeColor, "Should style inline code");
});
it("should preserve gray italic styling after bold text", () => {
const markdown = new Markdown(
"This is thinking with **bold text** and more after",
1,
0,
defaultMarkdownTheme,
{
color: (text) => chalk.gray(text),
italic: true,
},
);
const lines = markdown.render(80);
const joinedOutput = lines.join("\n");
// Should contain bold text
assert.ok(joinedOutput.includes("bold text"));
// The output should have ANSI codes for gray (90) and italic (3)
assert.ok(joinedOutput.includes("\x1b[90m"), "Should have gray color code");
assert.ok(joinedOutput.includes("\x1b[3m"), "Should have italic code");
// Should have bold codes (1 or 22 for bold on/off)
assert.ok(joinedOutput.includes("\x1b[1m"), "Should have bold code");
});
it("should not leak styles into following lines when rendered in TUI", async () => {
class MarkdownWithInput implements Component {
public markdownLineCount = 0;
constructor(private readonly markdown: Markdown) {}
render(width: number): string[] {
const lines = this.markdown.render(width);
this.markdownLineCount = lines.length;
return [...lines, "INPUT"];
}
invalidate(): void {
this.markdown.invalidate();
}
}
const markdown = new Markdown("This is thinking with `inline code`", 1, 0, defaultMarkdownTheme, {
color: (text) => chalk.gray(text),
italic: true,
});
const terminal = new VirtualTerminal(80, 6);
const tui = new TUI(terminal);
const component = new MarkdownWithInput(markdown);
tui.addChild(component);
tui.start();
await terminal.flush();
assert.ok(component.markdownLineCount > 0);
const inputRow = component.markdownLineCount;
assert.strictEqual(getCellItalic(terminal, inputRow, 0), 0);
tui.stop();
});
});
describe("Spacing after code blocks", () => {
it("should have only one blank line between code block and following paragraph", () => {
const markdown = new Markdown(
`hello world
\`\`\`js
const hello = "world";
\`\`\`
again, hello world`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const closingBackticksIndex = plainLines.indexOf("```");
assert.ok(closingBackticksIndex !== -1, "Should have closing backticks");
const afterBackticks = plainLines.slice(closingBackticksIndex + 1);
const emptyLineCount = afterBackticks.findIndex((line) => line !== "");
assert.strictEqual(
emptyLineCount,
1,
`Expected 1 empty line after code block, but found ${emptyLineCount}. Lines after backticks: ${JSON.stringify(afterBackticks.slice(0, 5))}`,
);
});
});
describe("Spacing after dividers", () => {
it("should have only one blank line between divider and following paragraph", () => {
const markdown = new Markdown(
`hello world
---
again, hello world`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const dividerIndex = plainLines.findIndex((line) => line.includes("─"));
assert.ok(dividerIndex !== -1, "Should have divider");
const afterDivider = plainLines.slice(dividerIndex + 1);
const emptyLineCount = afterDivider.findIndex((line) => line !== "");
assert.strictEqual(
emptyLineCount,
1,
`Expected 1 empty line after divider, but found ${emptyLineCount}. Lines after divider: ${JSON.stringify(afterDivider.slice(0, 5))}`,
);
});
});
describe("Spacing after headings", () => {
it("should have only one blank line between heading and following paragraph", () => {
const markdown = new Markdown(
`# Hello
This is a paragraph`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const headingIndex = plainLines.findIndex((line) => line.includes("Hello"));
assert.ok(headingIndex !== -1, "Should have heading");
const afterHeading = plainLines.slice(headingIndex + 1);
const emptyLineCount = afterHeading.findIndex((line) => line !== "");
assert.strictEqual(
emptyLineCount,
1,
`Expected 1 empty line after heading, but found ${emptyLineCount}. Lines after heading: ${JSON.stringify(afterHeading.slice(0, 5))}`,
);
});
});
describe("Spacing after blockquotes", () => {
it("should have only one blank line between blockquote and following paragraph", () => {
const markdown = new Markdown(
`hello world
> This is a quote
again, hello world`,
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, "").trimEnd());
const quoteIndex = plainLines.findIndex((line) => line.includes("This is a quote"));
assert.ok(quoteIndex !== -1, "Should have blockquote");
const afterQuote = plainLines.slice(quoteIndex + 1);
const emptyLineCount = afterQuote.findIndex((line) => line !== "");
assert.strictEqual(
emptyLineCount,
1,
`Expected 1 empty line after blockquote, but found ${emptyLineCount}. Lines after quote: ${JSON.stringify(afterQuote.slice(0, 5))}`,
);
});
});
describe("Links", () => {
it("should not duplicate URL for autolinked emails", () => {
const markdown = new Markdown("Contact user@example.com for help", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const joinedPlain = plainLines.join(" ");
// Should contain the email once, not duplicated with mailto:
assert.ok(joinedPlain.includes("user@example.com"), "Should contain email");
assert.ok(!joinedPlain.includes("mailto:"), "Should not show mailto: prefix for autolinked emails");
});
it("should not duplicate URL for bare URLs", () => {
const markdown = new Markdown("Visit https://example.com for more", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const joinedPlain = plainLines.join(" ");
// URL should appear only once
const urlCount = (joinedPlain.match(/https:\/\/example\.com/g) || []).length;
assert.strictEqual(urlCount, 1, "URL should appear exactly once");
});
it("should show URL for explicit markdown links with different text", () => {
const markdown = new Markdown("[click here](https://example.com)", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const joinedPlain = plainLines.join(" ");
// Should show both link text and URL
assert.ok(joinedPlain.includes("click here"), "Should contain link text");
assert.ok(joinedPlain.includes("(https://example.com)"), "Should show URL in parentheses");
});
it("should show URL for explicit mailto links with different text", () => {
const markdown = new Markdown("[Email me](mailto:test@example.com)", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const joinedPlain = plainLines.join(" ");
// Should show both link text and mailto URL
assert.ok(joinedPlain.includes("Email me"), "Should contain link text");
assert.ok(joinedPlain.includes("(mailto:test@example.com)"), "Should show mailto URL in parentheses");
});
});
describe("HTML-like tags in text", () => {
it("should render content with HTML-like tags as text", () => {
// When the model emits something like <thinking>content</thinking> in regular text,
// marked might treat it as HTML and hide the content
const markdown = new Markdown(
"This is text with <thinking>hidden content</thinking> that should be visible",
0,
0,
defaultMarkdownTheme,
);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const joinedPlain = plainLines.join(" ");
// The content inside the tags should be visible
assert.ok(
joinedPlain.includes("hidden content") || joinedPlain.includes("<thinking>"),
"Should render HTML-like tags or their content as text, not hide them",
);
});
it("should render HTML tags in code blocks correctly", () => {
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0, defaultMarkdownTheme);
const lines = markdown.render(80);
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
const joinedPlain = plainLines.join("\n");
// HTML in code blocks should be visible
assert.ok(
joinedPlain.includes("<div>") && joinedPlain.includes("</div>"),
"Should render HTML in code blocks",
);
});
});
});