返回 DeepSeek-Reasonix
composer-inbox-interleaving.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-inbox-interleaving.test.tsx
1 // Run: tsx src/__tests__/composer-inbox-interleaving.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 { LocaleProvider } from "../lib/i18n";
9 import { ToastProvider } from "../lib/toast";
10 import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
11 import { installDesktopHostStub } from "./desktopHostStub";
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 function deferred<T>() {
36 let resolve!: (value: T) => void;
37 const promise = new Promise<T>((next) => {
38 resolve = next;
39 });
40 return { promise, resolve };
41 }
42
43 class TestResizeObserver {
44 observe() {}
45 unobserve() {}
46 disconnect() {}
47 }
48
49 function installDom() {
50 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
51 pretendToBeVisual: true,
52 url: "http://localhost/",
53 });
54 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
55 globalThis.window = dom.window as unknown as Window & typeof globalThis;
56 globalThis.document = dom.window.document;
57 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
58 globalThis.Node = dom.window.Node;
59 globalThis.HTMLElement = dom.window.HTMLElement;
60 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
61 globalThis.Event = dom.window.Event;
62 globalThis.CustomEvent = dom.window.CustomEvent;
63 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
64 globalThis.InputEvent = dom.window.InputEvent;
65 globalThis.MouseEvent = dom.window.MouseEvent;
66 globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
67 globalThis.MutationObserver = dom.window.MutationObserver;
68 globalThis.localStorage = dom.window.localStorage;
69 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
70 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
71 globalThis.ResizeObserver = TestResizeObserver;
72 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
73 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
74 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", { configurable: true, value: () => {} });
75 Object.defineProperty(window, "matchMedia", {
76 configurable: true,
77 value: () => ({
78 matches: true,
79 media: "(prefers-reduced-motion: reduce)",
80 onchange: null,
81 addEventListener() {},
82 removeEventListener() {},
83 addListener() {},
84 removeListener() {},
85 dispatchEvent: () => false,
86 }),
87 });
88 return dom;
89 }
90
91 function installBridgeApp(methods: Record<string, unknown>) {
92 installDesktopHostStub(({
93 main: {
94 App: {
95 Commands: async () => [],
96 Models: async () => [],
97 ModelsForTab: async () => [],
98 ListDir: async () => [],
99 ListDirForTab: async () => [],
100 ListDirForTarget: async () => [],
101 SearchFileRefs: async () => [],
102 SearchFileRefsForTab: async () => [],
103 SearchFileRefsForTarget: async () => [],
104 ...methods,
105 },
106 },
107 }).main.App);
108 }
109
110 async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}) {
111 const rootElement = document.getElementById("root");
112 if (!rootElement) throw new Error("missing root");
113 const root = createRoot(rootElement);
114 let currentProps: Parameters<typeof Composer>[0] = {
115 running: false,
116 collaborationMode: "normal",
117 toolApprovalMode: "ask" as ToolApprovalMode,
118 goal: "",
119 cwd: "/repo",
120 modelLabel: "DeepSeek-R1",
121 tabId: "tab-a",
122 sessionKey: "session:project:/repo:topic-a:session-a",
123 onSend: () => {},
124 onCancel: async () => ({ discardedItemIds: [] }),
125 onCycleMode: () => {},
126 onSetMode: () => {},
127 onSetCollaborationMode: (_mode: CollaborationMode) => {},
128 onSetToolApprovalMode: () => {},
129 onClearGoal: () => {},
130 onSwitchModel: () => {},
131 onSetEffort: () => {},
132 ready: true,
133 ...props,
134 };
135 const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
136 const switchingDraft = nextProps.sessionKey !== undefined && nextProps.sessionKey !== currentProps.sessionKey;
137 currentProps = {
138 ...currentProps,
139 ...(switchingDraft ? { insertRequest: null, selectedTextRequest: null } : {}),
140 ...nextProps,
141 };
142 await act(async () => {
143 root.render(
144 <LocaleProvider>
145 <ToastProvider>
146 <Composer {...currentProps} />
147 </ToastProvider>
148 </LocaleProvider>,
149 );
150 await flushTimers();
151 });
152 };
153 await paint();
154 return { root, rerender: paint };
155 }
156
157 console.log("\ncomposer inbox interleaving");
158
159 {
160 // A fast tool boundary may consume a newly accepted steer before the bridge
161 // enqueue promise resolves. The late receipt must not resurrect the chip.
162 const dom = installDom();
163 const enqueueStarted = deferred<void>();
164 const enqueueReceipt = deferred<{ itemId: string; disposition: string; position: number; paused: boolean }>();
165 installBridgeApp({
166 EnqueueInboxSteer: async () => {
167 enqueueStarted.resolve();
168 return enqueueReceipt.promise;
169 },
170 });
171 const { root, rerender } = await renderComposer({ running: true });
172 await rerender({ insertRequest: { id: 7001, text: "consume before receipt", mode: "replace" } });
173 const sendButton = document.querySelector(".composer__btn--send") as HTMLButtonElement | null;
174 if (!sendButton) throw new Error("running composer send button did not render for early consume");
175 await act(async () => {
176 sendButton.click();
177 await enqueueStarted.promise;
178 });
179 await rerender({
180 guidanceConsumedKey: "early-consume-event",
181 guidanceConsumedItemId: "early-consumed-item",
182 guidanceConsumedText: "consume before receipt",
183 });
184 await act(async () => {
185 enqueueReceipt.resolve({ itemId: "early-consumed-item", disposition: "steer_accepted", position: 1, paused: false });
186 await flushTimers();
187 });
188 ok(document.querySelector(".composer-guidance-item") === null, "an early consume event prevents the late enqueue receipt from resurrecting guidance");
189
190 await act(async () => root.unmount());
191 dom.window.close();
192 }
193
194 {
195 // Stop settlement belongs to its source draft. A slow cancellation in A
196 // must not disable or swallow a separate stop request after switching to B.
197 const dom = installDom();
198 installBridgeApp({});
199 const cancelAStarted = deferred<void>();
200 const cancelAReceipt = deferred<{ discardedItemIds: string[] }>();
201 const cancelledTabs: string[] = [];
202 const { root, rerender } = await renderComposer({
203 running: true,
204 onCancel: () => {
205 cancelledTabs.push("tab-a");
206 cancelAStarted.resolve();
207 return cancelAReceipt.promise;
208 },
209 });
210 let stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
211 if (!stopButton) throw new Error("session A stop button did not render");
212 await act(async () => {
213 stopButton?.click();
214 await cancelAStarted.promise;
215 });
216
217 await rerender({
218 running: true,
219 tabId: "tab-b",
220 sessionKey: "session:project:/repo:topic-b:session-b",
221 onCancel: async () => {
222 cancelledTabs.push("tab-b");
223 return { discardedItemIds: [] };
224 },
225 });
226 stopButton = document.querySelector(".composer__btn--stop") as HTMLButtonElement | null;
227 if (!stopButton) throw new Error("session B stop button did not render");
228 ok(stopButton.disabled === false, "session B stop stays enabled while session A cancellation settles");
229 await act(async () => {
230 stopButton?.click();
231 await flushTimers();
232 });
233 eq(cancelledTabs.join(","), "tab-a,tab-b", "session B can issue its own stop while session A cancellation settles");
234
235 await act(async () => {
236 cancelAReceipt.resolve({ discardedItemIds: [] });
237 await flushTimers();
238 root.unmount();
239 });
240 dom.window.close();
241 }
242
243 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
244 if (failed > 0) process.exit(1);
245
245 lines Plain Text