返回 DeepSeek-Reasonix
use-controller-cancel-reconcile.test.tsx
根目录 / desktop / frontend / src / __tests__ / use-controller-cancel-reconcile.test.tsx
1 // Run: tsx src/__tests__/use-controller-cancel-reconcile.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import { useController } from "../lib/useController";
7 import type { AppBindings } from "../lib/bridge";
8 import type { ContextInfo, EffortInfo, Meta, TabMeta, WireEvent } from "../lib/types";
9
10 let passed = 0;
11 let failed = 0;
12
13 function ok(value: boolean, label: string) {
14 if (value) {
15 process.stdout.write(` PASS ${label}\n`);
16 passed += 1;
17 } else {
18 process.stdout.write(` FAIL ${label}\n`);
19 failed += 1;
20 }
21 }
22
23 function eq(actual: unknown, expected: unknown, label: string) {
24 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
25 }
26
27 function flushPromises(): Promise<void> {
28 return new Promise((resolve) => setTimeout(resolve, 0));
29 }
30
31 async function waitFor(label: string, predicate: () => boolean) {
32 for (let attempt = 0; attempt < 50; attempt += 1) {
33 await act(async () => {
34 await flushPromises(20);
35 });
36 if (predicate()) return;
37 }
38 throw new Error(`timed out waiting for ${label}`);
39 }
40
41 function tabMeta(overrides: Partial<TabMeta> = {}): TabMeta {
42 return {
43 id: "tab-a",
44 scope: "project",
45 workspaceRoot: "/repo",
46 workspaceName: "repo",
47 workspacePath: "/repo",
48 topicId: "topic-a",
49 topicTitle: "General",
50 label: "model",
51 ready: true,
52 running: false,
53 cancellable: false,
54 mode: "normal",
55 toolApprovalMode: "ask",
56 tokenMode: "full",
57 active: true,
58 cwd: "/repo",
59 ...overrides,
60 };
61 }
62
63 function meta(): Meta {
64 return {
65 label: "model",
66 ready: true,
67 eventChannel: "agent:event",
68 cwd: "/repo",
69 workspaceRoot: "/repo",
70 workspaceName: "repo",
71 workspacePath: "/repo",
72 autoApproveTools: false,
73 bypass: false,
74 collaborationMode: "normal",
75 toolApprovalMode: "ask",
76 tokenMode: "full",
77 goal: "",
78 goalStatus: "stopped",
79 };
80 }
81
82 console.log("\nuse controller cancel reconcile");
83
84 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
85 pretendToBeVisual: true,
86 url: "http://localhost/",
87 });
88 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
89 globalThis.window = dom.window as unknown as Window & typeof globalThis;
90 globalThis.document = dom.window.document;
91 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
92 globalThis.Node = dom.window.Node;
93 globalThis.HTMLElement = dom.window.HTMLElement;
94 globalThis.Event = dom.window.Event;
95 globalThis.CustomEvent = dom.window.CustomEvent;
96 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
97 globalThis.MouseEvent = dom.window.MouseEvent;
98 globalThis.localStorage = dom.window.localStorage;
99 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
100 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
101
102 const eventHandlers: Array<(e: WireEvent) => void> = [];
103 let backendRunning = false;
104 let cancelCalls = 0;
105 let effortCalls = 0;
106 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
107 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
108
109 window.runtime = {
110 EventsOn: (name: string, cb: (payload: unknown) => void) => {
111 if (name === "agent:event") eventHandlers.push(cb as (e: WireEvent) => void);
112 return () => {};
113 },
114 BrowserOpenURL: () => {},
115 };
116 window.go = {
117 main: {
118 App: {
119 ListTabs: async () => [tabMeta({ running: backendRunning, cancellable: backendRunning })],
120 MetaForTab: async () => meta(),
121 ContextUsageForTab: async () => context,
122 EffortForTab: async () => effort,
123 SetEffortForTab: async () => {
124 effortCalls += 1;
125 throw new Error("finish or cancel the current turn, answer pending prompts, and stop background jobs before changing effort");
126 },
127 BalanceForTab: async () => ({ available: false, display: "" }),
128 JobsForTab: async () => [],
129 CheckpointsForTab: async () => [],
130 HistoryForTab: async () => [],
131 HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }),
132 HistoryCheckpointTurnsForTab: async () => [],
133 ReplayPendingPrompts: async () => {},
134 CancelTab: async () => {
135 cancelCalls += 1;
136 backendRunning = false;
137 },
138 } as Partial<AppBindings> as AppBindings,
139 },
140 };
141
142 type Controller = ReturnType<typeof useController>;
143 let controller: Controller | undefined;
144
145 function Probe() {
146 controller = useController();
147 return null;
148 }
149
150 const rootEl = document.getElementById("root");
151 if (!rootEl) throw new Error("missing root");
152 const root = createRoot(rootEl);
153
154 await act(async () => {
155 root.render(<Probe />);
156 await flushPromises();
157 });
158 await waitFor("active tab", () => controller?.activeTabId === "tab-a");
159 await act(async () => {
160 await flushPromises(50);
161 });
162
163 backendRunning = true;
164 await act(async () => {
165 for (const handler of eventHandlers) handler({ kind: "turn_started", tabId: "tab-a" });
166 await flushPromises();
167 });
168 eq(controller?.state.running, true, "turn_started marks the tab running");
169
170 await act(async () => {
171 controller?.cancel();
172 await flushPromises();
173 await flushPromises();
174 });
175
176 for (let attempt = 0; attempt < 20 && controller?.state.running; attempt += 1) {
177 await act(async () => {
178 await flushPromises(50);
179 });
180 }
181
182 eq(controller?.state.running, false, "cancel reconciliation clears the running state");
183 eq(cancelCalls, 1, "CancelTab is called once");
184 eq(controller?.state.cancelRequested, false, "cancel reconciliation clears cancelRequested");
185
186 await act(async () => {
187 await controller?.setEffort("max");
188 await flushPromises();
189 });
190
191 const effortNotice = controller?.state.items.find((item) => item.kind === "notice" && item.text.includes("cannot change yet"));
192 eq(effortCalls, 1, "SetEffortForTab is called once");
193 ok(Boolean(effortNotice), "busy effort switch surfaces a non-failure warning notice");
194
195 await act(async () => {
196 root.unmount();
197 });
198 dom.window.close();
199
200 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
201 if (failed > 0) process.exit(1);
202
202 lines Plain Text