返回 DeepSeek-Reasonix
chat-file-references.test.tsx
根目录 / desktop / frontend / src / __tests__ / chat-file-references.test.tsx
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
336 lines Plain Text