返回 DeepSeek-Reasonix
composer-ime.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-ime.test.tsx
1 // Run: tsx src/__tests__/composer-ime.test.tsx
2 //
3 // The plain composer textarea must survive CJK IME composition (#8593/#8409).
4 // While a composition is active the textarea renders uncontrolled so no
5 // unrelated re-render (autosize, run-strip ticker, selection tracking) can
6 // write node.value and cancel the in-flight composition; compositionend is
7 // the authoritative resync point, and a programmatic setText landing
8 // mid-composition forces a resync instead of being swallowed by the freeze.
9 //
10 // jsdom note: these suites install the DOM after react-dom has loaded, so
11 // React boots with canUseDOM=false and falls back to its keyup-driven
12 // change-detection polyfill — the input event feeds onInputCapture
13 // (inputType) while the trailing keyup is what delivers onChange. The
14 // composition listeners in the IME guard hook are native, so plain
15 // compositionstart/compositionend Events reach them directly.
16
17 import { JSDOM } from "jsdom";
18 import React from "react";
19 import { act } from "react";
20 import { createRoot } from "react-dom/client";
21 import { Composer } from "../components/Composer";
22 import { LocaleProvider } from "../lib/i18n";
23 import { ToastProvider } from "../lib/toast";
24 import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
25
26 let passed = 0;
27 let failed = 0;
28
29 function ok(value: boolean, label: string) {
30 if (value) {
31 process.stdout.write(` PASS ${label}\n`);
32 passed += 1;
33 } else {
34 process.stdout.write(` FAIL ${label}\n`);
35 failed += 1;
36 }
37 }
38
39 function eq(actual: unknown, expected: unknown, label: string) {
40 if (actual === expected) ok(true, label);
41 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
42 }
43
44 function flushTimers(): Promise<void> {
45 return new Promise((resolve) => setTimeout(resolve, 0));
46 }
47
48 class TestResizeObserver {
49 observe() {}
50 unobserve() {}
51 disconnect() {}
52 }
53
54 function installDom() {
55 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
56 pretendToBeVisual: true,
57 url: "http://localhost/",
58 });
59 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
60 globalThis.window = dom.window as unknown as Window & typeof globalThis;
61 globalThis.document = dom.window.document;
62 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
63 globalThis.Node = dom.window.Node;
64 globalThis.HTMLElement = dom.window.HTMLElement;
65 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
66 globalThis.Event = dom.window.Event;
67 globalThis.CustomEvent = dom.window.CustomEvent;
68 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
69 globalThis.InputEvent = dom.window.InputEvent;
70 globalThis.MouseEvent = dom.window.MouseEvent;
71 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
72 globalThis.MutationObserver = dom.window.MutationObserver;
73 globalThis.File = dom.window.File;
74 globalThis.FileReader = dom.window.FileReader;
75 globalThis.localStorage = dom.window.localStorage;
76 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
77 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
78 globalThis.ResizeObserver = TestResizeObserver;
79 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
80 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
81 Object.defineProperty(window, "matchMedia", {
82 configurable: true,
83 value: () => ({
84 matches: true,
85 media: "(prefers-reduced-motion: reduce)",
86 onchange: null,
87 addEventListener() {},
88 removeEventListener() {},
89 addListener() {},
90 removeListener() {},
91 dispatchEvent: () => false,
92 }),
93 });
94 return dom;
95 }
96
97 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
98 const rootEl = document.getElementById("root");
99 if (!rootEl) throw new Error("missing root");
100 const root = createRoot(rootEl);
101 const sent: string[] = [];
102 let currentProps: Parameters<typeof Composer>[0] = {
103 running: false,
104 collaborationMode: "normal" as CollaborationMode,
105 toolApprovalMode: "ask" as ToolApprovalMode,
106 goal: "",
107 cwd: "/repo",
108 modelLabel: "DeepSeek-R1",
109 onSend: (text) => {
110 sent.push(text);
111 },
112 onCancel: () => {},
113 onCycleMode: () => {},
114 onSetMode: () => {},
115 onSetCollaborationMode: () => {},
116 onSetToolApprovalMode: () => {},
117 onClearGoal: () => {},
118 onSwitchModel: () => {},
119 onSetEffort: () => {},
120 ready: true,
121 ...props,
122 };
123 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
124 currentProps = { ...currentProps, ...nextProps };
125 await act(async () => {
126 root.render(
127 <LocaleProvider>
128 <ToastProvider>
129 <Composer {...currentProps} />
130 </ToastProvider>
131 </LocaleProvider>,
132 );
133 await flushTimers();
134 });
135 };
136 await paint();
137 return { root, sent, rerender: paint };
138 }
139
140 // React's value tracker only reports a change (and fires onChange) when the
141 // DOM value moved outside its patched setter, so IME mutations must go
142 // through the native prototype setter — exactly how the browser itself
143 // applies provisional and committed composition text.
144 function setNativeValue(ta: HTMLTextAreaElement, value: string) {
145 const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set;
146 if (!setter) throw new Error("native textarea value setter unavailable");
147 setter.call(ta, value);
148 }
149
150 function dispatchInput(ta: HTMLTextAreaElement, data: string, inputType: string, isComposing: boolean) {
151 ta.dispatchEvent(new window.InputEvent("input", {
152 bubbles: true,
153 data,
154 inputType,
155 isComposing,
156 }));
157 // The change-detection polyfill delivers onChange on keyup.
158 ta.dispatchEvent(new window.KeyboardEvent("keyup", { key: "Process", bubbles: true }));
159 }
160
161 function pressEnter(ta: HTMLTextAreaElement) {
162 ta.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
163 }
164
165 function composerTextarea(): HTMLTextAreaElement {
166 const ta = document.querySelector<HTMLTextAreaElement>("#composer-input");
167 if (!ta) throw new Error("plain composer textarea did not render");
168 return ta;
169 }
170
171 console.log("\ncomposer plain textarea IME freeze");
172
173 // compositionstart → provisional DOM text → unrelated re-render → the
174 // provisional text must survive (pre-fix, React's updateTextarea compares
175 // the controlled value against the live DOM value on every commit and
176 // writes the stale state back, cancelling the composition — the swallowed
177 // first keystroke). Commit then resyncs exactly once and leaves a sane caret.
178 {
179 const dom = installDom();
180 const { root, sent, rerender } = await renderComposer();
181 const ta = composerTextarea();
182
183 await act(async () => {
184 ta.focus();
185 ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
186 // WebView2 ordering: the provisional character is already in the DOM
187 // before the first input event reaches React.
188 setNativeValue(ta, "n");
189 await flushTimers();
190 });
191
192 // An unrelated re-render (parent prop change, run-strip tick) lands while
193 // the composition is in flight and no input event has fired yet.
194 await rerender({ modelLabel: "DeepSeek-V4" });
195 eq(ta.value, "n", "unrelated re-render mid-composition keeps the provisional text");
196
197 await act(async () => {
198 setNativeValue(ta, "ni");
199 dispatchInput(ta, "i", "insertCompositionText", true);
200 await flushTimers();
201 });
202 eq(ta.value, "ni", "composition input keeps flowing without a DOM clobber");
203
204 await act(async () => {
205 pressEnter(ta);
206 await flushTimers();
207 });
208 eq(sent.length, 0, "Enter during composition does not send");
209
210 await act(async () => {
211 // Commit: the IME replaces the provisional run, fires the commit input
212 // (Chromium fires it before compositionend) and then compositionend.
213 setNativeValue(ta, "你");
214 ta.setSelectionRange(1, 1);
215 dispatchInput(ta, "你", "insertCompositionText", false);
216 ta.dispatchEvent(new Event("compositionend", { bubbles: true }));
217 await flushTimers();
218 });
219 eq(ta.value, "你", "committed IME text is intact after compositionend");
220 eq(ta.selectionStart, 1, "caret sits after the committed character");
221
222 await act(async () => {
223 pressEnter(ta);
224 await flushTimers();
225 });
226 eq(sent.length, 0, "Enter inside the post-composition grace window does not send");
227
228 await act(async () => {
229 await new Promise((resolve) => setTimeout(resolve, 150));
230 pressEnter(ta);
231 await flushTimers();
232 });
233 eq(sent.length, 1, "Enter after the grace window sends");
234 eq(sent[0], "你", "the sent text is the committed IME text");
235
236 await act(async () => {
237 root.unmount();
238 });
239 dom.window.close();
240 }
241
242 // IME cancel: compositionstart, provisional text, then compositionend with
243 // the DOM restored to the pre-composition value (no commit input). State
244 // must follow the authoritative DOM, and typing afterwards must keep working.
245 {
246 const dom = installDom();
247 const { root } = await renderComposer();
248 const ta = composerTextarea();
249
250 await act(async () => {
251 ta.focus();
252 ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
253 setNativeValue(ta, "tmp");
254 dispatchInput(ta, "p", "insertCompositionText", true);
255 await flushTimers();
256 });
257 eq(ta.value, "tmp", "provisional text is live during composition");
258
259 await act(async () => {
260 setNativeValue(ta, "");
261 ta.dispatchEvent(new Event("compositionend", { bubbles: true }));
262 await flushTimers();
263 });
264 eq(ta.value, "", "IME cancel restores the pre-composition text");
265
266 await act(async () => {
267 setNativeValue(ta, "x");
268 ta.setSelectionRange(1, 1);
269 dispatchInput(ta, "x", "insertText", false);
270 await flushTimers();
271 });
272 eq(ta.value, "x", "typing after an IME cancel keeps working");
273
274 await act(async () => {
275 root.unmount();
276 });
277 dom.window.close();
278 }
279
280 // Programmatic setText mid-composition (history recall, menu inserts, draft
281 // switches) bypasses the textarea's onChange, so it must force an
282 // authoritative resync through the freeze instead of being swallowed.
283 {
284 const dom = installDom();
285 const { root, sent, rerender } = await renderComposer();
286 const ta = composerTextarea();
287
288 await act(async () => {
289 ta.focus();
290 ta.dispatchEvent(new Event("compositionstart", { bubbles: true }));
291 setNativeValue(ta, "n");
292 ta.setSelectionRange(1, 1);
293 dispatchInput(ta, "n", "insertCompositionText", true);
294 await flushTimers();
295 });
296 eq(ta.value, "n", "composition is in flight before the programmatic insert");
297
298 await rerender({ insertRequest: { id: 7, text: "snippet body" } });
299 eq(
300 ta.value,
301 "n\n\nsnippet body\n",
302 "programmatic insert forces a resync and reaches the DOM mid-composition",
303 );
304
305 await act(async () => {
306 pressEnter(ta);
307 await flushTimers();
308 });
309 eq(sent.length, 1, "the programmatic resync ends the freeze so Enter sends");
310 ok(sent[0]?.includes("snippet body") === true, "the sent text carries the programmatic insert");
311
312 await act(async () => {
313 root.unmount();
314 });
315 dom.window.close();
316 }
317
318 console.log(`\n${passed} passed, ${failed} failed`);
319 if (failed > 0) process.exit(1);
320
320 lines Plain Text