From d1161bd1b5a905eaf1d5b8115aecbea74ae86428 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 18 Sep 2026 18:50:57 +0900 Subject: [PATCH] test(workspace): preserve markdown compatibility boundaries --- web/workspace/deno.lock | 1 + .../lib/workspace/console/MarkdownLink.svelte | 19 +- .../console/RichMarkdown.browser.spec.ts | 200 ++++++++++++++---- .../lib/workspace/console/RichMarkdown.svelte | 11 +- 4 files changed, 188 insertions(+), 43 deletions(-) diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 448a14620..43e70c552 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -22,6 +22,7 @@ "npm:devalue@5.6.4": "5.6.4", "npm:gen-interface-jp@0.8.0": "0.8.0", "npm:happy-dom@18.0.1": "18.0.1", + "npm:marked@18.0.13": "18.0.13", "npm:set-cookie-parser@2.7.2": "2.7.2", "npm:shiki@4.4.2": "4.4.2", "npm:sv@*": "0.10.8", diff --git a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte b/web/workspace/src/lib/workspace/console/MarkdownLink.svelte index fd1cd2bf4..7f3f13ada 100644 --- a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte +++ b/web/workspace/src/lib/workspace/console/MarkdownLink.svelte @@ -10,8 +10,23 @@ title?: string; children?: Snippet; } = $props(); + + let safeHref = $derived(httpHref(href)); + + function httpHref(value: string): string | undefined { + try { + const protocol = new URL(value).protocol.toLowerCase(); + return protocol === "http:" || protocol === "https:" ? value : undefined; + } catch { + return undefined; + } + } - +{#if safeHref} + + {@render children?.()} + +{:else} {@render children?.()} - +{/if} diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts b/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts index 42650f66a..788eabdbd 100644 --- a/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts @@ -6,42 +6,22 @@ import RichMarkdown from "./RichMarkdown.svelte"; afterEach(cleanup); -test("preserves safe links, blocks raw HTML, and renders Shiki code", async () => { - const source = [ - "[unsafe](javascript:alert(1)) and [safe](https://example.com)", - "", - '', - "", - "```ts", - "const answer: number = 42;", - "```", - ].join("\n"); - const { container } = render(RichMarkdown, { - text: source, - streamId: "message-security", - }); - - await waitFor(() => { - expect(container.querySelectorAll("a")).toHaveLength(2); - expect(container.querySelector("pre.shiki code span")).not.toBeNull(); - }); - - const [unsafe, safe] = container.querySelectorAll("a"); - expect(unsafe.getAttribute("href")).toBe(""); - expect(safe.getAttribute("href")).toBe("https://example.com"); - expect(safe.getAttribute("target")).toBe("_blank"); - expect(safe.getAttribute("rel")).toBe("noreferrer"); - expect(container.querySelector("img")).toBeNull(); - expect(container.querySelector("script")).toBeNull(); -}); - -test("keeps completed DOM nodes while a later markdown block grows", async () => { +test("keeps completed block nodes while a trailing inline token grows", async () => { const initial = [ "# Stable heading", "", "This paragraph is already complete.", "", - "The final paragraph is stream", + "- stable list item", + "- another item", + "", + "> stable quote", + "", + "```ts", + "const answer: number = 42;", + "```", + "", + "The final paragraph has **partial", ].join("\n"); const { container, rerender } = render(RichMarkdown, { text: initial, @@ -50,21 +30,163 @@ test("keeps completed DOM nodes while a later markdown block grows", async () => await waitFor(() => { expect(container.querySelector("h1")?.textContent).toBe("Stable heading"); - expect(container.querySelectorAll("p")).toHaveLength(2); + expect(container.querySelector("ul")).not.toBeNull(); + expect(container.querySelector("blockquote")).not.toBeNull(); + expect(container.querySelector("pre.shiki code span")).not.toBeNull(); }); - const heading = container.querySelector("h1"); - const completedParagraph = container.querySelector("p"); + const stableNodes = [ + container.querySelector("h1"), + container.querySelector("p"), + container.querySelector("ul"), + container.querySelector("blockquote"), + container.querySelector("pre"), + ]; await rerender({ - text: `${initial}ed incrementally.`, + text: `${initial} emphasis** and a [safe link](https://example.com).`, streamId: "message-1", }); await waitFor(() => { - expect(container.querySelectorAll("p")[1]?.textContent).toBe( - "The final paragraph is streamed incrementally.", + expect(container.querySelector("strong")?.textContent).toBe( + "partial emphasis", ); + expect(container.querySelector('a[href="https://example.com"]')).not + .toBeNull(); }); - expect(container.querySelector("h1")).toBe(heading); - expect(container.querySelector("p")).toBe(completedParagraph); + expect(container.querySelector("h1")).toBe(stableNodes[0]); + expect(container.querySelector("p")).toBe(stableNodes[1]); + expect(container.querySelector("ul")).toBe(stableNodes[2]); + expect(container.querySelector("blockquote")).toBe(stableNodes[3]); + expect(container.querySelector("pre")).toBe(stableNodes[4]); }); + +test("resets the DOM for non-prefix rewrites and stream identity changes", async () => { + const { container, rerender } = render(RichMarkdown, { + text: "# Original\n\nOriginal body.", + streamId: "message-1", + }); + await waitFor(() => { + expect(container.querySelector("h1")?.textContent).toBe("Original"); + }); + await rerender({ + text: "# Corrected\n\nRewritten body.", + streamId: "message-1", + }); + await waitFor(() => { + expect(container.querySelector("h1")?.textContent).toBe("Corrected"); + }); + expect(container.textContent).not.toContain("Original body."); + + await rerender({ + text: "# Corrected\n\nRewritten body for a new message.", + streamId: "message-2", + }); + await waitFor(() => { + expect(container.querySelector("p")?.textContent).toBe( + "Rewritten body for a new message.", + ); + }); + expect(container.querySelector("h1")?.textContent).toBe("Corrected"); + expect(container.textContent).not.toContain("Original body."); +}); + +test("preserves line breaks and the HTTP(S)-only link boundary", async () => { + const source = [ + "first line\nsecond line", + "", + "[https](https://example.com) [http](http://example.com)", + "[script](javascript:alert(1)) [data](data:text/html,hello)", + "[mail](mailto:test@example.com) [relative](/internal)", + "", + '', + ].join("\n"); + const { container } = render(RichMarkdown, { + text: source, + streamId: "message-security", + }); + + await waitFor(() => { + expect(container.querySelectorAll("a")).toHaveLength(2); + expect(container.querySelector("br")).not.toBeNull(); + }); + + const links = container.querySelectorAll("a"); + expect(links[0].getAttribute("href")).toBe("https://example.com"); + expect(links[1].getAttribute("href")).toBe("http://example.com"); + expect(links[0].getAttribute("target")).toBe("_blank"); + expect(links[0].getAttribute("rel")).toBe("noreferrer"); + expect(container.textContent).toContain("script"); + expect(container.textContent).toContain("relative"); + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("script")).toBeNull(); +}); + +test("keeps prior content through an unclosed fence and unknown-language fallback", async () => { + const initial = [ + "Stable paragraph.", + "", + "```not-a-real-language", + "raw ", + ].join("\n"); + const { container, rerender } = render(RichMarkdown, { + text: initial, + streamId: "message-fence", + }); + await waitFor(() => { + expect(container.querySelector("pre.shiki-fallback code")?.textContent) + .toContain( + "raw ", + ); + }); + const stableParagraph = container.querySelector("p"); + + await rerender({ + text: `${initial}\n\`\`\`\n\nAfter the fence.`, + streamId: "message-fence", + }); + await waitFor(() => { + expect(container.querySelectorAll("p")).toHaveLength(2); + expect(container.querySelectorAll("p")[1]?.textContent).toBe( + "After the fence.", + ); + }); + expect(container.querySelector("p")).toBe(stableParagraph); + expect(container.querySelector("pre.shiki-fallback code")?.textContent) + .toContain( + "raw ", + ); +}); + +test("reuses early DOM nodes across a long streaming append", async () => { + const firstBatch = Array.from( + { length: 80 }, + (_, index) => `Paragraph ${index}.`, + ); + const { container, rerender } = render(RichMarkdown, { + text: firstBatch.join("\n\n"), + streamId: "message-long", + }); + await waitFor( + () => expect(container.querySelectorAll("p")).toHaveLength(80), + { timeout: 5_000 }, + ); + const first = container.querySelectorAll("p")[0]; + const middle = container.querySelectorAll("p")[40]; + + const secondBatch = Array.from( + { length: 40 }, + (_, index) => `Paragraph ${index + 80}.`, + ); + await rerender({ + text: [...firstBatch, ...secondBatch].join("\n\n"), + streamId: "message-long", + }); + await waitFor( + () => expect(container.querySelectorAll("p")).toHaveLength(120), + { timeout: 5_000 }, + ); + + expect(container.querySelectorAll("p")[0]).toBe(first); + expect(container.querySelectorAll("p")[40]).toBe(middle); +}, 10_000); diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte index c609ec121..93772d795 100644 --- a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte +++ b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte @@ -23,6 +23,7 @@ import SvelteMarkdown, { buildUnsupportedHTML, type Renderers, + type SvelteMarkdownProps, } from "@humanspeak/svelte-markdown"; import { ShikiCode } from "@humanspeak/svelte-markdown/extensions/shiki"; import MarkdownLink from "$lib/workspace/console/MarkdownLink.svelte"; @@ -35,6 +36,11 @@ let { text, streamId = "static", class: className = "" }: Props = $props(); + const options = { + breaks: true, + gfm: true, + } satisfies NonNullable; + const renderers = { code: ShikiCode, html: buildUnsupportedHTML(), @@ -43,7 +49,7 @@
- +