返回 DeepSeek-Reasonix
ready-event-meta-sync.test.tsx
根目录 / desktop / frontend / src / __tests__ / ready-event-meta-sync.test.tsx
1 // Run: tsx src/__tests__/ready-event-meta-sync.test.tsx
2
3 import { JSDOM } from "jsdom";
4 import React, { act } from "react";
5 import { createRoot } from "react-dom/client";
6 import type { AppBindings } from "../lib/bridge";
7 import { useController } from "../lib/useController";
8 import { historySliceFromMessages } from "./mockHistorySlice";
9 import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta } from "../lib/types";
10 import { installDesktopHostStub } from "./desktopHostStub";
11
12 let passed = 0;
13 let failed = 0;
14
15 function ok(value: boolean, label: string) {
16 if (value) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}\n`);
21 failed += 1;
22 }
23 }
24
25 function eq(actual: unknown, expected: unknown, label: string) {
26 if (actual === expected) {
27 ok(true, label);
28 } else {
29 ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
30 }
31 }
32
33 function flushPromises(): Promise<void> {
34 return new Promise((resolve) => setTimeout(resolve, 0));
35 }
36
37 function deferred<T>() {
38 let resolve!: (value: T) => void;
39 let reject!: (reason?: unknown) => void;
40 const promise = new Promise<T>((res, rej) => {
41 resolve = res;
42 reject = rej;
43 });
44 return { promise, resolve, reject };
45 }
46
47 async function waitFor(label: string, predicate: () => boolean) {
48 for (let attempt = 0; attempt < 30; attempt += 1) {
49 await act(async () => {
50 await flushPromises();
51 });
52 if (predicate()) return;
53 }
54 throw new Error(`timed out waiting for ${label}`);
55 }
56
57 function tabMeta(ready: boolean): TabMeta {
58 return {
59 id: "tab-ready",
60 scope: "project",
61 workspaceRoot: "/repo",
62 workspaceName: "repo",
63 workspacePath: "/repo",
64 gitBranch: "main",
65 topicId: "topic-ready",
66 topicTitle: "Ready race",
67 sessionPath: "/repo/sessions/ready.jsonl",
68 label: "model",
69 ready,
70 running: false,
71 mode: "normal",
72 toolApprovalMode: "ask",
73 tokenMode: "full",
74 active: true,
75 cwd: "/repo",
76 };
77 }
78
79 function meta(ready: boolean): Meta {
80 return {
81 label: "model",
82 ready,
83 eventChannel: "agent:event",
84 cwd: "/repo",
85 workspaceRoot: "/repo",
86 workspaceName: "repo",
87 workspacePath: "/repo",
88 gitBranch: "main",
89 autoApproveTools: false,
90 bypass: false,
91 collaborationMode: "normal",
92 toolApprovalMode: "ask",
93 tokenMode: "full",
94 goal: "",
95 goalStatus: "stopped",
96 };
97 }
98
99 console.log("\nready event meta sync");
100
101 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
102 pretendToBeVisual: true,
103 url: "http://localhost/",
104 });
105 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
106 globalThis.window = dom.window as unknown as Window & typeof globalThis;
107 globalThis.document = dom.window.document;
108 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
109 globalThis.Node = dom.window.Node;
110 globalThis.HTMLElement = dom.window.HTMLElement;
111 globalThis.Event = dom.window.Event;
112 globalThis.CustomEvent = dom.window.CustomEvent;
113 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
114 globalThis.MouseEvent = dom.window.MouseEvent;
115 globalThis.localStorage = dom.window.localStorage;
116 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
117 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
118
119 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
120 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
121 const balance: BalanceInfo = { available: false, display: "" };
122 const jobs: JobView[] = [];
123 const checkpoints: CheckpointMeta[] = [];
124 let historyGate = deferred<HistoryMessage[]>();
125 let backendReady = false;
126 let listTabsCalls = 0;
127 let historyCalls = 0;
128 let metaCalls = 0;
129
130 const desktopStub = installDesktopHostStub(({
131 main: {
132 App: {
133 ListTabs: async () => {
134 listTabsCalls += 1;
135 return [tabMeta(backendReady)];
136 },
137 MetaForTab: async () => {
138 metaCalls += 1;
139 return meta(backendReady);
140 },
141 ContextUsageForTab: async () => context,
142 EffortForTab: async () => effort,
143 BalanceForTab: async () => balance,
144 JobsForTab: async () => jobs,
145 CheckpointsForTab: async () => checkpoints,
146 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
147 HistoryForTab: async () => historyGate.promise,
148 HistoryPageForTab: async () => {
149 historyCalls += 1;
150 const messages = await historyGate.promise;
151 return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false };
152 },
153 HistorySliceForTab: async (tabId: string, req: HistorySliceRequest) => {
154 historyCalls += 1;
155 return historySliceFromMessages(tabId, await historyGate.promise, req);
156 },
157 HistoryCheckpointTurnsForTab: async () => [],
158 ReplayPendingPrompts: async () => {},
159 } as Partial<AppBindings> as AppBindings,
160 },
161 }).main.App);
162
163 type Controller = ReturnType<typeof useController>;
164 let controller: Controller | undefined;
165
166 function Probe() {
167 controller = useController();
168 return null;
169 }
170
171 const rootEl = document.getElementById("root");
172 if (!rootEl) throw new Error("missing root");
173 const root = createRoot(rootEl);
174
175 await act(async () => {
176 root.render(<Probe />);
177 await flushPromises();
178 });
179
180 await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
181 eq(historyCalls, 1, "startup begins one history hydration");
182 eq(metaCalls, 0, "startup history remains in flight before ancillary meta");
183
184 backendReady = true;
185 await act(async () => {
186 desktopStub.emit("agent:ready", "tab-ready");
187 await flushPromises();
188 });
189 await waitFor("ready metadata refreshed before history settles", () => controller?.state.meta?.ready === true);
190
191 eq(listTabsCalls >= 2, true, "ready event refreshes active tab metadata from ListTabs");
192 eq(historyCalls, 1, "ready event joins the in-flight hydrate instead of reloading history");
193 eq(metaCalls, 0, "ready event does not wait for ancillary MetaForTab before unlocking send");
194
195 await act(async () => {
196 historyGate.resolve([{ role: "user", content: "hello" }]);
197 await historyGate.promise;
198 await flushPromises();
199 });
200 await waitFor("history finishes", () => controller?.state.hydrating === false);
201 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after the ready metadata sync");
202
203 // A failed cut can precede runtime readiness (or an explicit retry). Recovery
204 // must clear the current error without leaving a second, permanent chat notice.
205 for (const recovery of ["ready", "retry"] as const) {
206 historyGate = deferred<HistoryMessage[]>();
207 await act(async () => {
208 const pending = controller!.retrySessionHistory();
209 historyGate.reject(new Error("runtime changed while following transcript"));
210 await pending;
211 await flushPromises();
212 });
213 ok(Boolean(controller?.state.hydrateError), `${recovery}: real failure remains reportable`);
214 ok(controller?.state.items.some(item => item.kind === "user" && item.text === "hello") ?? false, `${recovery}: failure preserves history`);
215 historyGate = deferred<HistoryMessage[]>();
216 historyGate.resolve([{ role: "user", content: "hello" }]);
217 await act(async () => {
218 if (recovery === "ready") desktopStub.emit("agent:ready", "tab-ready");
219 else await controller!.retrySessionHistory();
220 await flushPromises();
221 });
222 await waitFor(`${recovery}: recovered history`, () => controller?.state.hydrating === false && !controller.state.hydrateError);
223 ok(!controller?.state.items.some(item => item.kind === "notice" && item.text.includes("Failed to load conversation history")), `${recovery}: recovery leaves no obsolete history failure notice`);
224 }
225
226 await act(async () => {
227 root.unmount();
228 });
229 dom.window.close();
230
231 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
232 if (failed > 0) process.exit(1);
233
233 lines Plain Text