返回 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, TokenMode, ToolApprovalMode } from "../lib/types";
12
13 let passed = 0;
14 let failed = 0;
15
16 function ok(value: boolean, label: string) {
17 if (value) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}\n`);
22 failed += 1;
23 }
24 }
25
26 function eq(actual: unknown, expected: unknown, label: string) {
27 if (actual === expected) ok(true, label);
28 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
29 }
30
31 function flushTimers(): Promise<void> {
32 return new Promise((resolve) => setTimeout(resolve, 0));
33 }
34
35 async function waitFor(check: () => boolean, attempts = 10): Promise<void> {
36 for (let i = 0; i < attempts; i++) {
37 if (check()) return;
38 await act(async () => {
39 await flushTimers();
40 });
41 }
42 }
43
44 class TestResizeObserver {
45 observe() {}
46 unobserve() {}
47 disconnect() {}
48 }
49
50 function installDom() {
51 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
52 pretendToBeVisual: true,
53 url: "http://localhost/",
54 });
55 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
56 globalThis.window = dom.window as unknown as Window & typeof globalThis;
57 globalThis.document = dom.window.document;
58 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
59 globalThis.Node = dom.window.Node;
60 globalThis.HTMLElement = dom.window.HTMLElement;
61 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
62 globalThis.Event = dom.window.Event;
63 globalThis.CustomEvent = dom.window.CustomEvent;
64 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
65 globalThis.InputEvent = dom.window.InputEvent;
66 globalThis.MouseEvent = dom.window.MouseEvent;
67 globalThis.File = dom.window.File;
68 globalThis.FileReader = dom.window.FileReader;
69 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
70 globalThis.MutationObserver = dom.window.MutationObserver;
71 globalThis.localStorage = dom.window.localStorage;
72 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
73 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
74 globalThis.ResizeObserver = TestResizeObserver;
75 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
76 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
77 Object.defineProperty(window, "matchMedia", {
78 configurable: true,
79 value: () => ({
80 matches: true,
81 media: "(prefers-reduced-motion: reduce)",
82 onchange: null,
83 addEventListener() {},
84 removeEventListener() {},
85 addListener() {},
86 removeListener() {},
87 dispatchEvent: () => false,
88 }),
89 });
90 return dom;
91 }
92
93 function installBridgeApp(methods: Record<string, unknown>) {
94 (window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
95 main: {
96 App: {
97 Commands: async () => [],
98 Models: async () => [],
99 ModelsForTab: async () => [],
100 ...methods,
101 },
102 },
103 };
104 }
105
106 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
107 const rootEl = document.getElementById("root");
108 if (!rootEl) throw new Error("missing root");
109 const root = createRoot(rootEl);
110 let currentProps: Parameters<typeof Composer>[0] = {
111 running: false,
112 collaborationMode: "normal",
113 toolApprovalMode: "ask" as ToolApprovalMode,
114 tokenMode: "full" as TokenMode,
115 goal: "",
116 cwd: "/repo",
117 modelLabel: "DeepSeek-R1",
118 imageInputEnabled: true,
119 tabId: "single-surface-tab",
120 sessionKey: "session:project:/repo:topic-a:session-a",
121 onSend: () => {},
122 onCancel: () => undefined,
123 onCycleMode: () => {},
124 onSetMode: () => {},
125 onSetCollaborationMode: (_mode: CollaborationMode) => {},
126 onSetToolApprovalMode: () => {},
127 onToggleYoloApprovalMode: () => {},
128 onClearGoal: () => {},
129 onSwitchModel: () => {},
130 onSetEffort: () => {},
131 onSetTokenMode: () => {},
132 ready: true,
133 ...props,
134 };
135 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
136 currentProps = { ...currentProps, ...nextProps };
137 await act(async () => {
138 root.render(
139 <LocaleProvider>
140 <ToastProvider>
141 <div className="chat-pane">
142 <Composer {...currentProps} />
143 </div>
144 </ToastProvider>
145 </LocaleProvider>,
146 );
147 await flushTimers();
148 });
149 };
150 await paint();
151 return { root, rerender: paint };
152 }
153
154 function textarea(): HTMLTextAreaElement {
155 const node = document.querySelector("textarea") as HTMLTextAreaElement | null;
156 if (!node) throw new Error("composer textarea did not render");
157 return node;
158 }
159
160 function sendButton(): HTMLButtonElement {
161 const node = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
162 if (!node) throw new Error("send button did not render");
163 return node;
164 }
165
166 function contextItemCount(): number {
167 return document.querySelectorAll(".composer-context__item").length;
168 }
169
170 function toastText(): string {
171 const items = Array.from(document.querySelectorAll(".toast__text"));
172 return (items.at(-1)?.textContent ?? "").trim();
173 }
174
175 function imagePasteEvent(file: File): Event {
176 const event = new Event("paste", { bubbles: true, cancelable: true });
177 Object.defineProperty(event, "clipboardData", {
178 configurable: true,
179 value: {
180 files: [file],
181 items: [],
182 types: [file.type],
183 getData: () => "",
184 },
185 });
186 return event;
187 }
188
189 function imageViewerOpen(): boolean {
190 return Boolean(document.querySelector(".image-viewer-backdrop .image-viewer__image"));
191 }
192
193 function renderUserMessage(text: string, props: Partial<Parameters<typeof UserMessage>[0]> = {}) {
194 const rootEl = document.getElementById("root");
195 if (!rootEl) throw new Error("missing root");
196 const root = createRoot(rootEl);
197 const paint = async () => {
198 await act(async () => {
199 root.render(
200 <LocaleProvider>
201 <div className="chat-pane">
202 <UserMessage text={text} {...props} />
203 </div>
204 </LocaleProvider>,
205 );
206 await flushTimers();
207 });
208 };
209 return { root, paint };
210 }
211
212 console.log("\ncomposer image capability");
213
214 {
215 const dom = installDom();
216 let saveCalls = 0;
217 installBridgeApp({
218 SavePastedImage: async () => {
219 saveCalls += 1;
220 return ".reasonix/attachments/mock.png";
221 },
222 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
223 });
224 const { root } = await renderComposer({ imageInputEnabled: false });
225 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
226
227 await act(async () => {
228 textarea().dispatchEvent(imagePasteEvent(file));
229 await flushTimers();
230 await flushTimers();
231 });
232 await waitFor(() => contextItemCount() === 1);
233
234 eq(saveCalls, 1, "text-only model stores pasted image attachments as tool-readable refs");
235 eq(contextItemCount(), 1, "text-only model keeps the pasted image attachment in the draft");
236 eq(toastText(), "", "text-only image attach does not warn before send");
237 eq(document.querySelector(".composer__prompt") === null, true, "image attach warning does not render inside the composer layout");
238
239 await act(async () => {
240 root.unmount();
241 });
242 dom.window.close();
243 }
244
245 {
246 const dom = installDom();
247 const sent: Array<{ display: string; submit?: string }> = [];
248 installBridgeApp({
249 SavePastedImage: async () => ".reasonix/attachments/mock.png",
250 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
251 });
252 const { root, rerender } = await renderComposer({
253 imageInputEnabled: true,
254 onSend: (display, submit) => sent.push({ display, submit }),
255 });
256 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
257
258 await act(async () => {
259 textarea().dispatchEvent(imagePasteEvent(file));
260 await flushTimers();
261 await flushTimers();
262 });
263 await waitFor(() => contextItemCount() === 1);
264 eq(contextItemCount(), 1, "vision-capable model keeps the pasted image attachment");
265
266 await rerender({ imageInputEnabled: false, insertRequest: { id: 1, text: "describe this image", mode: "insert" } });
267 eq(toastText(), "", "model switch alone does not show a warning toast");
268 await act(async () => {
269 sendButton().click();
270 await flushTimers();
271 });
272
273 eq(sent.length, 1, "switching to a text-only model still sends the image ref for tool use");
274 ok(toastText().includes("will not receive images directly"), "text-only send warns about direct image input without blocking");
275 eq(document.querySelector(".composer__prompt") === null, true, "image-input warning does not render inside the composer layout");
276 ok(sent[0]?.submit?.includes("@.reasonix/attachments/mock.png") === true, "submitted text retains the local image attachment ref");
277
278 await act(async () => {
279 root.unmount();
280 });
281 dom.window.close();
282 }
283
284 {
285 const dom = installDom();
286 installBridgeApp({
287 SavePastedImage: async () => ".reasonix/attachments/mock.png",
288 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
289 });
290 const { root } = await renderComposer({ imageInputEnabled: true });
291 const file = new File(["img"], "photo.png", { type: "image/png", lastModified: 1 });
292
293 await act(async () => {
294 textarea().dispatchEvent(imagePasteEvent(file));
295 await flushTimers();
296 await flushTimers();
297 });
298 await waitFor(() => Boolean(document.querySelector(".composer-context__thumb img")));
299 const thumb = document.querySelector(".composer-context__thumb") as HTMLElement | null;
300 if (!thumb) throw new Error("missing composer image thumbnail");
301 await act(async () => {
302 thumb.click();
303 await flushTimers();
304 });
305 await waitFor(imageViewerOpen);
306 ok(imageViewerOpen(), "composer image thumbnail opens the image viewer");
307
308 await act(async () => {
309 document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
310 await flushTimers();
311 });
312 await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
313 ok(!document.querySelector(".image-viewer-backdrop"), "composer image viewer closes on Escape");
314
315 await act(async () => {
316 root.unmount();
317 });
318 dom.window.close();
319 }
320
321 {
322 const dom = installDom();
323 installBridgeApp({
324 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
325 });
326 const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)");
327 await paint();
328 await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
329 const thumb = document.querySelector(".msg-attachment--image") as HTMLElement | null;
330 if (!thumb) throw new Error("missing message image thumbnail");
331 await act(async () => {
332 thumb.click();
333 await flushTimers();
334 });
335 await waitFor(() => Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")));
336 ok(Boolean(document.querySelector(".chat-pane > .image-viewer-backdrop")), "sent message image preview portals into the chat pane");
337
338 const close = document.querySelector(".image-viewer__close") as HTMLButtonElement | null;
339 if (!close) throw new Error("missing image viewer close button");
340 await act(async () => {
341 close.click();
342 await flushTimers();
343 });
344 await waitFor(() => !document.querySelector(".image-viewer-backdrop"));
345 ok(!document.querySelector(".image-viewer-backdrop"), "sent message image viewer closes from the close button");
346
347 await act(async () => {
348 root.unmount();
349 });
350 dom.window.close();
351 }
352
353 {
354 const dom = installDom();
355 installBridgeApp({
356 AttachmentDataURL: async () => "data:image/png;base64,iVBORw0KGgo=",
357 });
358 const { root, paint } = renderUserMessage("check @[photo.png](.reasonix/attachments/mock.png)", {
359 turn: 1,
360 onEdit: () => true,
361 });
362 await paint();
363 await waitFor(() => Boolean(document.querySelector(".msg-attachment--image img")));
364 const edit = document.querySelector("button.msg-meta__btn:not(.msg-meta__copy)") as HTMLButtonElement | null;
365 if (!edit) throw new Error("missing message edit button");
366 await act(async () => {
367 edit.click();
368 await flushTimers();
369 });
370 await waitFor(() => Boolean(document.querySelector(".msg-edit .composer-context__thumb img")));
371 const thumb = document.querySelector(".msg-edit .composer-context__thumb") as HTMLElement | null;
372 if (!thumb) throw new Error("missing edit image thumbnail");
373 await act(async () => {
374 thumb.click();
375 await flushTimers();
376 });
377 await waitFor(imageViewerOpen);
378 ok(imageViewerOpen(), "edit message image thumbnail opens the image viewer");
379
380 await act(async () => {
381 root.unmount();
382 });
383 dom.window.close();
384 }
385
386 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
387 if (failed > 0) process.exit(1);
388
388 lines Plain Text