| 1 | // Run: tsx src/__tests__/chat-file-references.test.tsx |
| 2 | // |
| 3 | // Answer-named files end to end: extraction from the parsed Markdown AST, the |
| 4 | // host round trip that verifies them, and the rendering rules that keep an |
| 5 | // unverified path as ordinary text. |
| 6 | |
| 7 | import { JSDOM } from "jsdom"; |
| 8 | import React, { Fragment, act } from "react"; |
| 9 | import { renderToStaticMarkup } from "react-dom/server"; |
| 10 | import { createRoot } from "react-dom/client"; |
| 11 | import type { AppBindings } from "../lib/bridge"; |
| 12 | import type { ChatFileReference, ChatFileReferenceResult } from "../generated/desktopContract.generated"; |
| 13 | import { chatFileCandidates } from "../lib/chatFileCandidates"; |
| 14 | import { looksLikeSvgDocument, svgAspectRatio } from "../lib/svgDocument"; |
| 15 | import { linkifyLocalPaths } from "../lib/localPathLinks"; |
| 16 | import { parseMarkdownToBlocks } from "../lib/markdownPipeline"; |
| 17 | import { hastBlockToJsx } from "../lib/hastJsx"; |
| 18 | import { createComponents } from "../components/markdownComponents"; |
| 19 | import { LocaleProvider } from "../lib/i18n"; |
| 20 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 21 | |
| 22 | let passed = 0; |
| 23 | let failed = 0; |
| 24 | |
| 25 | function ok(value: boolean, label: string) { |
| 26 | if (value) { process.stdout.write(` PASS ${label}\n`); passed += 1; } |
| 27 | else { process.stdout.write(` FAIL ${label}\n`); failed += 1; } |
| 28 | } |
| 29 | |
| 30 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" }); |
| 31 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 32 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 33 | globalThis.document = dom.window.document; |
| 34 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 35 | globalThis.Node = dom.window.Node; |
| 36 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 37 | globalThis.Event = dom.window.Event; |
| 38 | |
| 39 | console.log("\nchat file references"); |
| 40 | |
| 41 | // ── Candidate extraction runs on the parsed AST ───────────────────────────── |
| 42 | function candidatesOf(text: string) { |
| 43 | return chatFileCandidates(parseMarkdownToBlocks(text)); |
| 44 | } |
| 45 | |
| 46 | ok(candidatesOf("See `/repo/out/图 (1).svg` now.").some(c => c.path === "/repo/out/图 (1).svg"), |
| 47 | "an inline code span keeps its spaces and CJK intact"); |
| 48 | ok(candidatesOf("Wrote `out/diagram.svg` for you.").some(c => c.path === "out/diagram.svg"), |
| 49 | "a relative inline-code path with a directory is a candidate"); |
| 50 | ok(candidatesOf("Wrote `diagram.svg` for you.").length === 0, |
| 51 | "a bare filename is left to the turn's known file set, never resolved by guess"); |
| 52 | ok(candidatesOf("Run `npm run build` first.").length === 0, "an ordinary command is not a candidate"); |
| 53 | ok(candidatesOf("Answer: `/api/v1/users` and `/usr/bin/env`.").length === 2, |
| 54 | "an inline code span is its own delimiter, so the host decides what it names"); |
| 55 | ok(candidatesOf("```svg\n<svg xmlns=\"http://www.w3.org/2000/svg\"><rect/></svg>\n```").length === 0, |
| 56 | "a fenced block's own text is never scanned"); |
| 57 | ok(candidatesOf("<svg width=\"1\" onload=\"/tmp/x.svg\">").length === 0, "raw markup is not scanned"); |
| 58 | ok(candidatesOf("[report](/tmp/report.pdf)").some(c => c.path === "/tmp/report.pdf"), |
| 59 | "an explicit Markdown file link is a candidate"); |
| 60 | ok(candidatesOf("[docs](https://example.com/a.md)").length === 0, "a remote link is not a candidate"); |
| 61 | const deduped = candidatesOf("`/a/b/c.svg` and again `/a/b/./c.svg`"); |
| 62 | ok(deduped.length === 1, "the same file spelled twice is one candidate"); |
| 63 | ok(candidatesOf("`out\\win\\nested.svg`").some(c => c.path === "out\\win\\nested.svg"), |
| 64 | "a Windows-style relative spelling survives extraction"); |
| 65 | |
| 66 | // ── Scanned POSIX prose paths are tagged for the renderer ─────────────────── |
| 67 | const scanned = linkifyLocalPaths("Saved to /Users/me/我的 项目 is not right, but /tmp/out.svg is."); |
| 68 | const scannedPath = scanned.find(segment => segment.path); |
| 69 | ok(scannedPath?.kind === "posix" && scannedPath.path === "/tmp/out.svg", |
| 70 | "a clearly delimited POSIX path in prose is recognised as a scanned path"); |
| 71 | ok(linkifyLocalPaths("See https://x.test/a.png").every(segment => segment.path === undefined), |
| 72 | "a URL is not scanned as a local path"); |
| 73 | |
| 74 | // ── SVG recognition ───────────────────────────────────────────────────────── |
| 75 | ok(looksLikeSvgDocument(`<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`), "a bare svg root is recognised"); |
| 76 | ok(looksLikeSvgDocument(`<?xml version="1.0"?>\n<!-- c -->\n<svg/>`), "a prolog and comment are tolerated"); |
| 77 | const adversarialCommentPrefix = `<!--${"--><!--".repeat(2_000)}x`; |
| 78 | const adversarialStarted = performance.now(); |
| 79 | ok(!looksLikeSvgDocument(adversarialCommentPrefix), "an unterminated adversarial comment is refused"); |
| 80 | ok(performance.now() - adversarialStarted < 100, "SVG prefix recognition stays linear"); |
| 81 | ok(!looksLikeSvgDocument("<html><body>x</body></html>"), "mixed HTML is not an SVG document"); |
| 82 | ok(!looksLikeSvgDocument("just text"), "ordinary text is not an SVG document"); |
| 83 | ok(svgAspectRatio(`<svg viewBox="0 0 200 100"></svg>`) === 2, "viewBox supplies the aspect ratio"); |
| 84 | ok(svgAspectRatio(`<svg width="30" height="10"></svg>`) === 3, "width and height supply the aspect ratio"); |
| 85 | ok(svgAspectRatio(`<svg viewBox="0 0 0 0"></svg>`) === undefined, "a degenerate viewBox has no ratio"); |
| 86 | |
| 87 | // ── Host round trip and rendering ─────────────────────────────────────────── |
| 88 | const referenceCalls: Array<{ turnKey: string; paths: string[] }> = []; |
| 89 | const desktopStub = installDesktopHostStub(({ |
| 90 | main: { |
| 91 | App: { |
| 92 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>): Promise<ChatFileReferenceResult> => { |
| 93 | referenceCalls.push({ turnKey, paths: candidates.map(candidate => candidate.path) }); |
| 94 | return { |
| 95 | turnKey, |
| 96 | references: candidates.map(candidate => candidate.path === "/repo/out/diagram.svg" |
| 97 | ? { key: candidate.key, path: candidate.path, status: "resolved", displayPath: "out/diagram.svg", kind: "image", actions: ["preview", "reveal-tree", "copy-path", "save-copy", "source", "open-native", "reveal-native"] } |
| 98 | : { key: candidate.key, path: candidate.path, status: "unavailable", actions: [], reason: "not-found" }), |
| 99 | }; |
| 100 | }, |
| 101 | SanitizeMarkdownSVG: async (content: string) => { |
| 102 | return content.includes("<script") |
| 103 | ? { ok: false, reason: "invalid" } |
| 104 | : { ok: true, svg: content }; |
| 105 | }, |
| 106 | } as Partial<AppBindings> as AppBindings, |
| 107 | }, |
| 108 | }).main.App); |
| 109 | |
| 110 | const markdownBlocks = parseMarkdownToBlocks("Wrote `/repo/out/diagram.svg` and claimed `/repo/out/missing.svg`.\n\nSaved at /tmp/scanned-only.svg as well."); |
| 111 | const components = createComponents(false); |
| 112 | |
| 113 | const { ChatFileScopeProvider, ChatFileTurnProvider, useChatFileCandidateReport } = await import("../components/ChatFileLinkContext"); |
| 114 | |
| 115 | /** Drives the production reporting hook, which MarkdownHistory calls after a parse. */ |
| 116 | function Reporter({ blocks }: { blocks: readonly { children: readonly unknown[] }[] }) { |
| 117 | useChatFileCandidateReport(blocks, 1); |
| 118 | return null; |
| 119 | } |
| 120 | const MarkdownSvgBlock = (await import("../components/MarkdownSvgBlock")).default; |
| 121 | |
| 122 | const rootEl = document.getElementById("root"); |
| 123 | if (!rootEl) throw new Error("missing root"); |
| 124 | const root = createRoot(rootEl); |
| 125 | |
| 126 | function paint(node: React.ReactNode) { |
| 127 | return act(async () => { root.render(<LocaleProvider>{node}</LocaleProvider>); }); |
| 128 | } |
| 129 | |
| 130 | await paint( |
| 131 | <ChatFileScopeProvider scopeKey="session-a" tabId="tab-a"> |
| 132 | <ChatFileTurnProvider turnKey="turn-1" factsVersion={1} presentedFiles={[]} modifiedFiles={[]} tabId="tab-a"> |
| 133 | <Reporter blocks={markdownBlocks} /> |
| 134 | {markdownBlocks.map(block => <Fragment key={block.key}>{hastBlockToJsx(block, components)}</Fragment>)} |
| 135 | </ChatFileTurnProvider> |
| 136 | </ChatFileScopeProvider>, |
| 137 | ); |
| 138 | await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); |
| 139 | |
| 140 | ok(referenceCalls.length > 0, "committed blocks ask the host to verify their candidates"); |
| 141 | ok(referenceCalls.every(call => call.turnKey === "turn-1"), "the host call carries the answer's turn"); |
| 142 | ok(referenceCalls.flatMap(call => call.paths).includes("/repo/out/diagram.svg"), "the inline-code path was submitted"); |
| 143 | ok(referenceCalls.flatMap(call => call.paths).every(path => path.startsWith("/") || path.includes("/")), |
| 144 | "only path-shaped candidates reach the host"); |
| 145 | |
| 146 | const body = () => rootEl.textContent ?? ""; |
| 147 | ok(body().includes("/repo/out/diagram.svg"), "the answer text is preserved verbatim"); |
| 148 | ok(document.querySelectorAll("button.md-code--presented-file").length === 1, |
| 149 | "a verified reference is clickable"); |
| 150 | ok(document.querySelector("button.md-code--presented-file")?.getAttribute("title") === "out/diagram.svg", |
| 151 | "the clickable reference points at the host's display path"); |
| 152 | ok(body().includes("/repo/out/missing.svg") && document.querySelectorAll("button.md-code--presented-file").length === 1, |
| 153 | "an unverified path stays ordinary text"); |
| 154 | ok(document.querySelector("span.md-rich-link__plain") !== null, |
| 155 | "a scanned prose path stays inert until the host confirms it"); |
| 156 | |
| 157 | // ── The SVG code block ────────────────────────────────────────────────────── |
| 158 | const svgSource = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 5"><linearGradient id="g"/><text x="1" y="1">hi</text></svg>`; |
| 159 | const svgMarkup = (value: string) => renderToStaticMarkup(<LocaleProvider><MarkdownSvgBlock value={value} /></LocaleProvider>); |
| 160 | |
| 161 | const pending = svgMarkup(svgSource); |
| 162 | ok(pending.includes("md-svg"), "an SVG block renders its own frame"); |
| 163 | ok(pending.includes("md-svg__note") === false, "a pending sanitize does not claim the source is unshowable"); |
| 164 | ok(pending.includes("linearGradient"), "the source is available while the preview is pending"); |
| 165 | |
| 166 | const svgBlocks = parseMarkdownToBlocks("```svg\n" + svgSource + "\n```"); |
| 167 | ok(svgBlocks.length === 1, "an svg fence parses as one block"); |
| 168 | await act(async () => { |
| 169 | root.render(<LocaleProvider> |
| 170 | {svgBlocks.map(block => <Fragment key={block.key}>{hastBlockToJsx(block, components)}</Fragment>)} |
| 171 | </LocaleProvider>); |
| 172 | }); |
| 173 | await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); |
| 174 | ok(document.querySelector(".md-svg") !== null, "an svg fence renders the preview block, not a plain code block"); |
| 175 | ok(document.querySelector(".md-svg__preview img") !== null, "the sanitized SVG becomes an image source, never injected markup"); |
| 176 | ok(/^(blob:|data:image\/svg\+xml)/.test(document.querySelector(".md-svg__preview img")?.getAttribute("src") ?? ""), |
| 177 | "the preview loads the host's sanitized bytes as an image source"); |
| 178 | ok(document.querySelector(".md-svg__note") === null, "a previewable SVG shows no fallback note"); |
| 179 | ok(Boolean(document.querySelector(".md-svg__copy")), "the block keeps a copy action for the original source"); |
| 180 | |
| 181 | const codeFence = parseMarkdownToBlocks("```html\n<div>not an svg</div>\n```"); |
| 182 | ok(codeFence.length === 1, "an html fence still parses"); |
| 183 | await act(async () => { |
| 184 | root.render(<LocaleProvider> |
| 185 | {codeFence.map(block => <Fragment key={block.key}>{hastBlockToJsx(block, components)}</Fragment>)} |
| 186 | </LocaleProvider>); |
| 187 | }); |
| 188 | ok(document.querySelector(".md-svg") === null && document.querySelector(".code-block") !== null, |
| 189 | "an html fence that is not a single SVG root keeps the ordinary code block"); |
| 190 | |
| 191 | const RefusedBlock = (await import("../components/MarkdownSvgBlock")).default; |
| 192 | const refused = renderToStaticMarkup(<LocaleProvider><RefusedBlock value={`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`} /></LocaleProvider>); |
| 193 | ok(refused.includes("md-svg"), "a refused SVG still renders inside the block frame"); |
| 194 | ok(refused.includes("script"), "the refused SVG keeps its source visible"); |
| 195 | |
| 196 | // ── The session owner batches, dedupes, and rejects late work ─────────────── |
| 197 | const batchCalls: string[][] = []; |
| 198 | let gate: (() => void) | null = null; |
| 199 | desktopStub.replaceCommands(({ |
| 200 | main: { |
| 201 | App: { |
| 202 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>) => { |
| 203 | batchCalls.push(candidates.map(candidate => candidate.path)); |
| 204 | if (gate) await new Promise<void>(resolve => { const open = gate!; gate = () => { open(); resolve(); }; }); |
| 205 | return { |
| 206 | turnKey, |
| 207 | references: candidates.map(candidate => ({ key: candidate.key, path: candidate.path, status: "resolved", displayPath: candidate.path, actions: ["preview"] })), |
| 208 | }; |
| 209 | }, |
| 210 | } as Partial<AppBindings> as AppBindings, |
| 211 | }, |
| 212 | }).main.App); |
| 213 | |
| 214 | const { ChatFileReferenceStore, CHAT_FILE_REFERENCE_BATCH_LIMIT } = await import("../lib/chatFileReferences"); |
| 215 | const flush = async () => { for (let i = 0; i < 6; i += 1) await Promise.resolve(); }; |
| 216 | |
| 217 | const store = new ChatFileReferenceStore("tab-b"); |
| 218 | const many = Array.from({ length: CHAT_FILE_REFERENCE_BATCH_LIMIT + 5 }, (_, index) => ({ key: `k${index}`, path: `/tmp/f${index}.svg` })); |
| 219 | store.report("turn-a", 1, many); |
| 220 | await flush(); |
| 221 | ok(batchCalls.length === 2, "a set larger than the batch limit is split"); |
| 222 | ok(batchCalls.every(batch => batch.length <= CHAT_FILE_REFERENCE_BATCH_LIMIT), "no batch exceeds the host contract"); |
| 223 | ok(store.getTurnSnapshot("turn-a").size === many.length, "every candidate keeps a cached verdict"); |
| 224 | |
| 225 | batchCalls.length = 0; |
| 226 | store.report("turn-a", 1, many); |
| 227 | await flush(); |
| 228 | ok(batchCalls.length === 0, "re-reporting the same candidates does not ask the host again"); |
| 229 | |
| 230 | const failedStore = new ChatFileReferenceStore("tab-c"); |
| 231 | desktopStub.replaceCommands(({ |
| 232 | main: { |
| 233 | App: { |
| 234 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>) => { |
| 235 | batchCalls.push(candidates.map(candidate => candidate.path)); |
| 236 | return { turnKey, references: candidates.map(candidate => ({ key: candidate.key, path: candidate.path, status: "unavailable", actions: [], reason: "not-found" })) }; |
| 237 | }, |
| 238 | } as Partial<AppBindings> as AppBindings, |
| 239 | }, |
| 240 | }).main.App); |
| 241 | batchCalls.length = 0; |
| 242 | failedStore.report("turn-a", 1, [{ key: "k", path: "/tmp/late.svg" }]); |
| 243 | await flush(); |
| 244 | ok(batchCalls.length === 1, "a missing file is asked about once"); |
| 245 | failedStore.report("turn-a", 1, [{ key: "k", path: "/tmp/late.svg" }]); |
| 246 | await flush(); |
| 247 | ok(batchCalls.length === 1, "a cached failure is not retried on every render"); |
| 248 | failedStore.report("turn-a", 2, [{ key: "k", path: "/tmp/late.svg" }]); |
| 249 | await flush(); |
| 250 | ok(batchCalls.length === 2, "new file facts make an earlier failure worth asking about again"); |
| 251 | |
| 252 | const lateStore = new ChatFileReferenceStore("tab-d"); |
| 253 | gate = () => {}; |
| 254 | desktopStub.replaceCommands(({ |
| 255 | main: { |
| 256 | App: { |
| 257 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>) => { |
| 258 | await new Promise<void>(resolve => { gate = () => resolve(); }); |
| 259 | return { turnKey, references: candidates.map(candidate => ({ key: candidate.key, path: candidate.path, status: "resolved", displayPath: candidate.path, actions: ["preview"] })) }; |
| 260 | }, |
| 261 | } as Partial<AppBindings> as AppBindings, |
| 262 | }, |
| 263 | }).main.App); |
| 264 | lateStore.report("turn-a", 1, [{ key: "k", path: "/tmp/inflight.svg" }]); |
| 265 | await flush(); |
| 266 | lateStore.dispose(); |
| 267 | gate?.(); |
| 268 | await flush(); |
| 269 | ok(lateStore.getTurnSnapshot("turn-a").size === 0, "a reply from a replaced session never reaches the new one"); |
| 270 | |
| 271 | // ── A remote transcript never resolves through the local host ─────────────── |
| 272 | let remoteCalls = 0; |
| 273 | desktopStub.replaceCommands(({ |
| 274 | main: { |
| 275 | App: { |
| 276 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>) => { |
| 277 | remoteCalls += 1; |
| 278 | return { turnKey, references: candidates.map(candidate => ({ key: candidate.key, path: candidate.path, status: "resolved", displayPath: candidate.path, actions: ["preview"] })) }; |
| 279 | }, |
| 280 | } as Partial<AppBindings> as AppBindings, |
| 281 | }, |
| 282 | }).main.App); |
| 283 | |
| 284 | const remoteBlocks = parseMarkdownToBlocks("Wrote `/srv/app/out/remote.svg` on the server."); |
| 285 | await act(async () => { |
| 286 | root.render(<LocaleProvider> |
| 287 | <ChatFileScopeProvider scopeKey="session-r" tabId="tab-r" hostId="remote-test"> |
| 288 | <ChatFileTurnProvider turnKey="turn-r" factsVersion={0} presentedFiles={[]} modifiedFiles={[]} tabId="tab-r" hostId="remote-test"> |
| 289 | <Reporter blocks={remoteBlocks} /> |
| 290 | {remoteBlocks.map(block => <Fragment key={block.key}>{hastBlockToJsx(block, components)}</Fragment>)} |
| 291 | </ChatFileTurnProvider> |
| 292 | </ChatFileScopeProvider> |
| 293 | </LocaleProvider>); |
| 294 | }); |
| 295 | await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); |
| 296 | ok(remoteCalls === 0, "a remote answer is never verified against the local filesystem"); |
| 297 | ok(document.querySelectorAll("button.md-code--presented-file").length === 0, |
| 298 | "an unverified remote path stays ordinary text rather than opening locally"); |
| 299 | ok((rootEl.textContent ?? "").includes("/srv/app/out/remote.svg"), "the remote answer text is untouched"); |
| 300 | |
| 301 | // ── A StrictMode mount replay must not kill the session store ─────────────── |
| 302 | let strictCalls = 0; |
| 303 | desktopStub.replaceCommands(({ |
| 304 | main: { |
| 305 | App: { |
| 306 | ResolveChatFileReferencesForTab: async (_tabId: string, turnKey: string, candidates: Array<{ key: string; path: string }>) => { |
| 307 | strictCalls += 1; |
| 308 | return { turnKey, references: candidates.map(candidate => ({ key: candidate.key, path: candidate.path, status: "resolved", displayPath: candidate.path, actions: ["preview"] })) }; |
| 309 | }, |
| 310 | } as Partial<AppBindings> as AppBindings, |
| 311 | }, |
| 312 | }).main.App); |
| 313 | const strictBlocks = parseMarkdownToBlocks("Wrote `/repo/out/strict.svg` here."); |
| 314 | await act(async () => { |
| 315 | root.render(<React.StrictMode> |
| 316 | <LocaleProvider> |
| 317 | <ChatFileScopeProvider scopeKey="session-s" tabId="tab-s"> |
| 318 | <ChatFileTurnProvider turnKey="turn-s" factsVersion={1} presentedFiles={[]} modifiedFiles={[]} tabId="tab-s"> |
| 319 | <Reporter blocks={strictBlocks} /> |
| 320 | {strictBlocks.map(block => <Fragment key={block.key}>{hastBlockToJsx(block, components)}</Fragment>)} |
| 321 | </ChatFileTurnProvider> |
| 322 | </ChatFileScopeProvider> |
| 323 | </LocaleProvider> |
| 324 | </React.StrictMode>); |
| 325 | }); |
| 326 | await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); |
| 327 | ok(strictCalls > 0, "a StrictMode mount replay does not leave the session with a disposed store"); |
| 328 | |
| 329 | // ── Teardown ──────────────────────────────────────────────────────────────── |
| 330 | await act(async () => { root.unmount(); }); |
| 331 | desktopStub.uninstall(); |
| 332 | dom.window.close(); |
| 333 | |
| 334 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 335 | if (failed > 0) process.exit(1); |
| 336 |