返回 DeepSeek-Reasonix
composer-image-capability.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-image-capability.test.tsx
1 // Run: tsx src/__tests__/composer-image-capability.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React from "react";
5 import { act } from "react";
6 import { createRoot } from "react-dom/client";
7 import { Composer } from "../components/Composer";
8 import { UserMessage } from "../components/Message";
9 import { LocaleProvider } from "../lib/i18n";
10 import { ToastProvider } from "../lib/toast";
11 import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
12 import { installDesktopHostStub } from "./desktopHostStub";
13
14 let passed = 0;
15 let failed = 0;
16
17 function ok(value: boolean, label: string) {
18 if (value) {
19 process.stdout.write(` PASS ${label}\n`);
20 passed += 1;
21 } else {
22 process.stdout.write(` FAIL ${label}\n`);
23 failed += 1;
24 }
25 }
26
27 function eq(actual: unknown, expected: unknown, label: string) {
28 if (actual === expected) ok(true, label);
29 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
30 }
31
32 function flushTimers(): Promise<void> {
33 return new Promise((resolve) => setTimeout(resolve, 0));
34 }
35
36 async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
37 for (let i = 0; i < attempts; i++) {
38 if (check()) return;
39 await act(async () => {
40 await flushTimers();
41 });
42 }
43 }
44
45 class TestResizeObserver {
46 observe() {}
47 unobserve() {}
48 disconnect() {}
49 }
50
51 function installDom() {
52 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
53 pretendToBeVisual: true,
54 url: "http://localhost/",
55 });
56 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
57 globalThis.window = dom.window as unknown as Window & typeof globalThis;
58 globalThis.document = dom.window.document;
59 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
60 globalThis.Node = dom.window.Node;
61 globalThis.HTMLElement = dom.window.HTMLElement;
62 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
63 globalThis.Event = dom.window.Event;
64 globalThis.CustomEvent = dom.window.CustomEvent;
65 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
66 globalThis.InputEvent = dom.window.InputEvent;
67 globalThis.MouseEvent = dom.window.MouseEvent;
68 globalThis.File = dom.window.File;
69 globalThis.FileReader = dom.window.FileReader;
70 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
71 globalThis.MutationObserver = dom.window.MutationObserver;
72 globalThis.localStorage = dom.window.localStorage;
73 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
74 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
75 globalThis.ResizeObserver = TestResizeObserver;
76 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
77 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
78 Object.defineProperty(window, "matchMedia", {
79 configurable: true,
80 value: () => ({
81 matches: true,
82 media: "(prefers-reduced-motion: reduce)",
83 onchange: null,
84 addEventListener() {},
85 removeEventListener() {},
86 addListener() {},
87 removeListener() {},
88 dispatchEvent: () => false,
89 }),
90 });
91 return dom;
92 }
93
94 function installBridgeApp(methods: Record<string, unknown>) {
95 const legacySave = methods.SavePastedImageForTarget as ((token: string, dataURL: string) => Promise<string>) | undefined;
96 const legacyPreview = methods.AttachmentDataURLForTarget as ((token: string, path: string) => Promise<string>) | undefined;
97 const unscopedPreview = methods.AttachmentDataURL as ((path: string) => Promise<string>) | undefined;
98 return installDesktopHostStub({
99 Commands: async () => [],
100 Models: async () => [],
101 ModelsForTab: async () => [],
102 CaptureAttachmentTarget: async () => ({ token: "test-attachment-target", capabilities: ["attachments-v2"] }),
103 ReleaseAttachmentTarget: async () => {},
104 StageImageForTarget: async (token: string, _operationID: string, displayName: string, mime: string, dataURL: string) => ({
105 draftId: "",
106 path: legacySave ? await legacySave(token, dataURL) : ".reasonix/attachments/mock.png",
107 displayName,
108 mime,
109 width: 1,
110 height: 1,
111 bytes: dataURL.length,
112 }),
113 ReadDraftImageForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
114 AttachmentDataURLForTarget: legacyPreview ?? (async () => "data:image/png;base64,iVBORw0KGgo="),
115 AttachmentDataURLForTab: async (_tabID: string, path: string) => unscopedPreview ? unscopedPreview(path) : "data:image/png;base64,iVBORw0KGgo=",
116 ...methods,
117 });
118 }
119
120 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
121 const rootEl = document.getElementById("root");
122 if (!rootEl) throw new Error("missing root");
123 const root = createRoot(rootEl);
124 let currentProps: Parameters<typeof Composer>[0] = {
125 running: false,
126 collaborationMode: "normal",
127 toolApprovalMode: "ask" as ToolApprovalMode,
128
129 goal: "",
130 cwd: "/repo",
131 modelLabel: "DeepSeek-R1",
132 imageInputEnabled: true,
133 tabId: "single-surface-tab",
134 sessionKey: "session:project:/repo:topic-a:session-a",
135 onSend: () => {},
136 onCancel: async () => ({ discardedItemIds: [] }),
137 onCycleMode: () => {},
138 onSetMode: () => {},
139 onSetCollaborationMode: (_mode: CollaborationMode) => {},
140 onSetToolApprovalMode: () => {},
141 onClearGoal: () => {},
142 onSwitchModel: () => {},
143 onSetEffort: () => {},
144
145 ready: true,
146 ...props,
147 };
148 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
149 currentProps = { ...currentProps, ...nextProps };
150 await act(async () => {
151 root.render(
152 <LocaleProvider>
153 <ToastProvider>
154 <div className="chat-pane">
155 <Composer {...currentProps} />
156 </div>
157 </ToastProvider>
158 </LocaleProvider>,
159 );
160 await flushTimers();
161 });
162 };
163 await paint();
164 return { root, rerender: paint };
165 }
166
167 function textarea(): HTMLTextAreaElement {
168 const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
169 if (!node) throw new Error("composer textarea did not render");
170 return node;
171 }
172
173 function sendButton(): HTMLButtonElement {
174 const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
175 if (!node) throw new Error("send button did not render");
176 return node;
177 }
178
179 function contextItemCount(): number {
180 return document.querySelectorAll(".composer-context__item").length;
181 }
182
183 function toastText(): string {
184 const items = Array.from(document.querySelectorAll(".toast__text"));
185 return (items.at(-1)?.textContent ?? "").trim();
186 }
187
188 function imagePasteEvent(file: File): Event {
189 const event = new Event("paste", { bubbles: true, cancelable: true });
190 Object.defineProperty(event, "clipboardData", {
191 configurable: true,
192 value: {
193 files: [file],
194 items: [],
195 types: [file.type],
196 getData: () => "",
197 },
198 });
199 return event;
200 }
201
202 function imageViewerOpen(): boolean {
203 return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
204 }
205
206 function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
207 const rootEl = document.getElementById("root");
208 if (!rootEl) throw new Error("missing root");
209 const root = createRoot(rootEl);
210 const paint = async () => {
211 await act(async () => {
212 root.render(
213 <LocaleProvider>
214 <div className="chat-pane">
215 <UserMessage text={text} {...props} />
216 </div>
217 </LocaleProvider>,
218 );
219 await flushTimers();
220 });
221 };
222 return { root, paint };
223 }
224
225 console.log("\ncomposer image capability");
226
227 async function verifyUnsupportedAttachmentCapability(
228 overrides: Record<string, unknown>,
229 file: File | undefined,
230 label: string,
231 ) {
232 const dom = installDom();
233 let unhandled = 0;
234 const onUnhandled = (event: PromiseRejectionEvent) => {
235 unhandled += 1;
236 event.preventDefault();
237 };
238 window.addEventListener("unhandledrejection", onUnhandled);
239 installBridgeApp(overrides);
240 const { root } = await renderComposer({
241 imageInputEnabled: true,
242 insertRequest: { id: 1, text: "keep this draft", mode: "replace" },
243 });
244 const event = imagePasteEvent(file ?? new File([], "", { type: "" }));
245 if (!file) {
246 Object.defineProperty(event, "clipboardData", {
247 configurable: true,
248 value: { files: [], items: [{ kind: "file", type: "image/png", getAsFile: () => null }], types: ["image/png"], getData: () => "" },
249 });
250 }
251 await act(async () => {
252 textarea().dispatchEvent(event);
253 await flushTimers();
254 await flushTimers();
255 });
256 await waitFor(() => toastText() !== "");
257 eq(textarea().value, "keep this draft", `${label} keeps draft text`);
258 eq(contextItemCount(), 0, `${label} does not add a partial attachment`);
259 ok(toastText().length > 0, `${label} reports an explicit attachment error`);
260 eq(unhandled, 0, `${label} produces no unhandled rejection`);
261 window.removeEventListener("unhandledrejection", onUnhandled);
262 await act(async () => root.unmount());
263 dom.window.close();
264 }
265
266 await verifyUnsupportedAttachmentCapability(
267 { CaptureAttachmentTarget: undefined },
268 new File(["img"], "photo.png", { type: "image/png", lastModified: 1 }),
269 "missing target capture",
270 );
271 await verifyUnsupportedAttachmentCapability(
272 { StageImageForTarget: undefined },
273 new File(["img"], "photo.png", { type: "image/png", lastModified: 1 }),
274 "missing image staging",
275 );
276 await verifyUnsupportedAttachmentCapability(
277 { SavePastedFileForTarget: undefined },
278 new File(["pdf"], "document.pdf", { type: "application/pdf", lastModified: 1 }),
279 "missing pasted-file save",
280 );
281 await verifyUnsupportedAttachmentCapability(
282 { SaveClipboardImageForTarget: undefined },
283 undefined,
284 "missing native clipboard image read",
285 );
286
287 {
288 const dom = installDom();
289 let saveCalls = 0;
290 installBridgeApp({
291 SavePastedImageForTarget: async () => {
292 saveCalls += 1;
293 return ".reasonix/attachments/mock.png";
294 },
295 AttachmentDataURLForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
296 });
297 const { root } = await renderComposer({ imageInputEnabled: false });
298 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
299
300 await act(async () => {
301 textarea().dispatchEvent(imagePasteEvent(file));
302 await flushTimers();
303 await flushTimers();
304 });
305 await waitFor(() => contextItemCount() === 1);
306
307 eq(saveCalls, 1, "text-only model stores pasted image attachments as tool-readable refs");
308 eq(contextItemCount(), 1, "text-only model keeps the pasted image attachment in the draft");
309 eq(toastText(), "", "text-only image attach does not warn before send");
310 eq(document.querySelector(".composer__prompt") === null, true, "image attach warning does not render inside the composer layout");
311
312 await act(async () => {
313 root.unmount();
314 });
315 dom.window.close();
316 }
317
318 {
319 const dom = installDom();
320 const sent: Array<{ display: string; submit?: string }> = [];
321 installBridgeApp({
322 SavePastedImageForTarget: async () => ".reasonix/attachments/mock.png",
323 AttachmentDataURLForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
324 });
325 const { root, rerender } = await renderComposer({
326 imageInputEnabled: true,
327 onSend: (display, submit) => sent.push({ display, submit }),
328 });
329 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
330
331 await act(async () => {
332 textarea().dispatchEvent(imagePasteEvent(file));
333 await flushTimers();
334 await flushTimers();
335 });
336 await waitFor(() => contextItemCount() === 1);
337 eq(contextItemCount(), 1, "vision-capable model keeps the pasted image attachment");
338
339 await rerender({ imageInputEnabled: false, insertRequest: { id: 1, text: "describe this image", mode: "insert" } });
340 eq(toastText(), "", "model switch alone does not show a warning toast");
341 await act(async () => {
342 sendButton().click();
343 await flushTimers();
344 });
345
346 eq(sent.length, 1, "switching to a text-only model still sends the image ref for tool use");
347 ok(toastText().includes("image-understanding model") || toastText().includes("图片理解模型"), "text-only send points to the image-understanding setting");
348 eq(document.querySelector(".composer__prompt") === null, true, "image-input warning does not render inside the composer layout");
349 ok(sent[0]?.submit?.includes("@.reasonix/attachments/mock.png") === true, "submitted text retains the local image attachment ref");
350
351 await act(async () => {
352 root.unmount();
353 });
354 dom.window.close();
355 }
356
357 {
358 const dom = installDom();
359 const sent: string[] = [];
360 installBridgeApp({
361 SavePastedImageForTarget: async () => ".reasonix/attachments/mock.png",
362 AttachmentDataURLForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
363 });
364 const { root } = await renderComposer({
365 imageInputEnabled: false,
366 imageUnderstandingEnabled: true,
367 onSend: (display) => sent.push(display),
368 });
369 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
370
371 await act(async () => {
372 textarea().dispatchEvent(imagePasteEvent(file));
373 await flushTimers();
374 await flushTimers();
375 });
376 await waitFor(() => contextItemCount() === 1);
377 await act(async () => {
378 sendButton().click();
379 await flushTimers();
380 });
381 eq(sent.length, 1, "configured image-understanding fallback still sends the image turn");
382 eq(toastText(), "", "configured image-understanding fallback suppresses the obsolete warning");
383
384 await act(async () => {
385 root.unmount();
386 });
387 dom.window.close();
388 }
389
390 {
391 const dom = installDom();
392 installBridgeApp({
393 SavePastedImageForTarget: async () => ".reasonix/attachments/mock.png",
394 AttachmentDataURLForTarget: async () => "data:image/png;base64,iVBORw0KGgo=",
395 });
396 const { root } = await renderComposer({ imageInputEnabled: true });
397 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
398
399 await act(async () => {
400 textarea().dispatchEvent(imagePasteEvent(file));
401 await flushTimers();
402 await flushTimers();
403 });
404 await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
405 const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
406 if (!thumb) throw new Error("missing composer image thumbnail");
407 await act(async () => {
408 thumb.click();
409 await flushTimers();
410 });
411 await waitFor(imageViewerOpen);
412 ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
413
414 await act(async () => {
415 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
416 await flushTimers();
417 });
418 await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
419 ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
420
421 await act(async () => {
422 root.unmount();
423 });
424 dom.window.close();
425 }
426
427 {
428 const dom = installDom();
429 installBridgeApp({
430 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
431 });
432 const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
433 await paint();
434 await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
435 const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
436 if (!thumb) throw new Error("missing message image thumbnail");
437 await act(async () => {
438 thumb.click();
439 await flushTimers();
440 });
441 await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
442 ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
443
444 const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
445 if (!close) throw new Error("missing image viewer close button");
446 await act(async () => {
447 close.click();
448 await flushTimers();
449 });
450 await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
451 ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
452
453 await act(async () => {
454 root.unmount();
455 });
456 dom.window.close();
457 }
458
459 {
460 const dom = installDom();
461 installBridgeApp({
462 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
463 });
464 const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
465 turn: 1,
466 });
467 await paint();
468 await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
469 ok(!document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)"), "sent messages expose copy without the retired edit entry");
470 const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
471 if (!thumb) throw new Error("missing sent image thumbnail");
472 await act(async () => {
473 thumb.click();
474 await flushTimers();
475 });
476 await waitFor(imageViewerOpen);
477 ok(imageViewerOpen(), "sent image thumbnail remains previewable without message editing");
478
479 await act(async () => {
480 root.unmount();
481 });
482 dom.window.close();
483 }
484
485 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
486 if (failed > 0) process.exit(1);
487
487 lines Plain Text