feat: enable rich text display for assistant messages

Add markdown-to-HTML rendering for assistant messages using Markdig with
HTML sanitization. Includes cached rendering to avoid lag during streaming,
styled markdown elements (code blocks, tables, lists, blockquotes) within
chat bubbles, and 18 unit tests covering rendering and XSS prevention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
local
2026-04-05 01:29:58 +01:00
parent d3300c7db9
commit 7a5c22593a
14 changed files with 735 additions and 5 deletions

View File

@@ -0,0 +1,140 @@
using ChatAgent.Client.Services;
namespace ChatAgent.Client.Tests;
/// <summary>
/// Tests for MarkdownService covering markdown rendering and HTML sanitization.
/// Each test verifies a specific markdown element or sanitization rule from the
/// rich-text-display spec.
/// </summary>
public class MarkdownServiceTests
{
private readonly MarkdownService _sut = new();
// --- Markdown rendering tests ---
[Fact]
public void ConvertToHtml_BoldText_RendersStrong()
{
var result = _sut.ConvertToHtml("**bold text**");
Assert.Contains("<strong>bold text</strong>", result);
}
[Fact]
public void ConvertToHtml_ItalicText_RendersEm()
{
var result = _sut.ConvertToHtml("*italic text*");
Assert.Contains("<em>italic text</em>", result);
}
[Fact]
public void ConvertToHtml_FencedCodeBlock_RendersPreCode()
{
var result = _sut.ConvertToHtml("```\nvar x = 1;\n```");
Assert.Contains("<pre>", result);
Assert.Contains("<code>", result);
Assert.Contains("var x = 1;", result);
}
[Fact]
public void ConvertToHtml_InlineCode_RendersCode()
{
var result = _sut.ConvertToHtml("Use `Console.WriteLine` here");
Assert.Contains("<code>Console.WriteLine</code>", result);
}
[Fact]
public void ConvertToHtml_UnorderedList_RendersUlLi()
{
var result = _sut.ConvertToHtml("- item one\n- item two");
Assert.Contains("<ul>", result);
Assert.Contains("<li>item one</li>", result);
Assert.Contains("<li>item two</li>", result);
}
[Fact]
public void ConvertToHtml_OrderedList_RendersOlLi()
{
var result = _sut.ConvertToHtml("1. first\n2. second");
Assert.Contains("<ol>", result);
Assert.Contains("<li>first</li>", result);
}
[Fact]
public void ConvertToHtml_Heading_RendersH2()
{
var result = _sut.ConvertToHtml("## My Heading");
Assert.Contains("<h2>My Heading</h2>", result);
}
[Fact]
public void ConvertToHtml_Table_RendersTableElements()
{
var markdown = "| Name | Value |\n|------|-------|\n| A | 1 |";
var result = _sut.ConvertToHtml(markdown);
Assert.Contains("<table>", result);
Assert.Contains("<th>", result);
Assert.Contains("<td>", result);
}
[Fact]
public void ConvertToHtml_Link_RendersAnchorWithHref()
{
var result = _sut.ConvertToHtml("[click here](https://example.com)");
Assert.Contains("<a", result);
Assert.Contains("href=\"https://example.com\"", result);
Assert.Contains("click here</a>", result);
}
[Fact]
public void ConvertToHtml_EmptyString_ReturnsEmpty()
{
Assert.Equal(string.Empty, _sut.ConvertToHtml(""));
Assert.Equal(string.Empty, _sut.ConvertToHtml(null!));
}
// --- Sanitization tests ---
[Fact]
public void ConvertToHtml_ScriptTag_IsStripped()
{
var result = _sut.ConvertToHtml("Hello <script>alert('xss')</script> world");
Assert.DoesNotContain("<script>", result);
Assert.DoesNotContain("alert", result);
}
[Fact]
public void ConvertToHtml_EventHandler_IsStripped()
{
var result = _sut.ConvertToHtml("<div onclick=\"alert('xss')\">test</div>");
Assert.DoesNotContain("onclick", result);
Assert.DoesNotContain("alert", result);
}
[Fact]
public void ConvertToHtml_ImgTag_IsStripped()
{
var result = _sut.ConvertToHtml("<img src=\"x\" onerror=\"alert('xss')\">");
Assert.DoesNotContain("<img", result);
Assert.DoesNotContain("onerror", result);
}
[Fact]
public void ConvertToHtml_SafeTagsPreserved()
{
var result = _sut.ConvertToHtml("**bold** and *italic* and `code`");
Assert.Contains("<strong>", result);
Assert.Contains("<em>", result);
Assert.Contains("<code>", result);
}
[Fact]
public void ConvertToHtml_AnchorOnlyKeepsHref()
{
// Markdig generates links from markdown — verify only href survives
var result = _sut.ConvertToHtml("[test](https://example.com)");
Assert.Contains("href=", result);
Assert.DoesNotContain("onclick", result);
Assert.DoesNotContain("style", result);
}
}