summaryrefslogtreecommitdiffhomepage
path: root/src/features/markdown/logic/markdown.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-19 23:48:50 +0900
committerAdam Malczewski <[email protected]>2026-10-19 23:48:50 +0900
commitaa63d6b9f4cb14535fd0191ab358c27f0ee62ce8 (patch)
treed089ad4db3522d5ba8338f6badd4f78ab24fbd7f /src/features/markdown/logic/markdown.test.ts
parent6122a90cf0c9f938945e684e19e74153b38e7b4b (diff)
downloaddispatch-web-aa63d6b9f4cb14535fd0191ab358c27f0ee62ce8.tar.gz
dispatch-web-aa63d6b9f4cb14535fd0191ab358c27f0ee62ce8.zip
feat(markdown): render local-path images inline via backend image route
Diffstat (limited to 'src/features/markdown/logic/markdown.test.ts')
-rw-r--r--src/features/markdown/logic/markdown.test.ts93
1 files changed, 92 insertions, 1 deletions
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts
index 90f1938..61d0d0c 100644
--- a/src/features/markdown/logic/markdown.test.ts
+++ b/src/features/markdown/logic/markdown.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, it } from "vitest";
-import { renderMarkdown } from "./markdown";
+import { renderMarkdown, renderMarkdownTail } from "./markdown";
+import { advance, createStreamingMarkdown } from "./streaming-markdown";
+
+/** The single rendered `<img>` src, or null. */
+function imageSrc(html: string): string | null {
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ return doc.querySelector("img")?.getAttribute("src") ?? null;
+}
describe("renderMarkdown", () => {
it("renders GFM markdown (headings, emphasis)", () => {
@@ -95,4 +102,88 @@ describe("renderMarkdown", () => {
it("returns an empty string for empty input", () => {
expect(renderMarkdown("")).toBe("");
});
+
+ describe("local-path images", () => {
+ const BASE = "http://localhost:24203";
+ const image = { apiBase: BASE, cwd: "/w/foo" };
+
+ it("rewrites a file:// URL to the backend image route", () => {
+ expect(imageSrc(renderMarkdown("![pic](file:///home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => {
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image: { apiBase: "" } }))).toBe(
+ "/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ expect(imageSrc(renderMarkdown("![pic](/home/u/pic.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`,
+ );
+ });
+
+ it("resolves a relative path against the conversation cwd", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(
+ imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE, cwd: "/w/foo/" } })),
+ ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("leaves a relative path untouched when no cwd is available", () => {
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image: { apiBase: BASE } }))).toBe(
+ "shot.png",
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)", { image }))).toBe(
+ `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`,
+ );
+ expect(imageSrc(renderMarkdown("![shot](shot.png)"))).toBe("shot.png");
+ });
+
+ it("leaves remote http(s) and data: images untouched", () => {
+ expect(imageSrc(renderMarkdown("![remote](https://example.com/a.png)", { image }))).toBe(
+ "https://example.com/a.png",
+ );
+ expect(imageSrc(renderMarkdown("![remote](http://example.com/a.png)", { image }))).toBe(
+ "http://example.com/a.png",
+ );
+ const data = "data:image/png;base64,iVBORw0KGgo=";
+ expect(imageSrc(renderMarkdown(`![inline](${data})`, { image }))).toBe(data);
+ });
+
+ it("does not rewrite image syntax inside fenced code", () => {
+ const html = renderMarkdown("```md\n![pic](/home/u/pic.png)\n```", { image });
+ expect(html).toContain("/home/u/pic.png");
+ expect(html).not.toContain("/local-image");
+ });
+
+ it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => {
+ const state = createStreamingMarkdown();
+ const tail = advance(state, "![pic](/home/u/pic.png)", { image });
+ expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ // The settled prefix must render with the same options too.
+ const settled = advance(state, "![pic](/home/u/pic.png)\n\nnext\n", { image });
+ expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`);
+
+ const seeded = renderMarkdownTail(
+ "![shot](shot.png)",
+ { fences: 0, bold: 0, code: 0 },
+ {
+ image,
+ },
+ );
+ expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+
+ it("invalidates the streaming cache when the image options change (async cwd)", () => {
+ const state = createStreamingMarkdown();
+ const text = "![shot](shot.png)\n\nnext\n";
+ advance(state, text);
+ expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet
+ const updated = advance(state, text, { image });
+ expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`);
+ });
+ });
});