返回 DeepSeek-Reasonix
ready-meta-reconcile.test.tsx
根目录 / desktop / frontend / src / __tests__ / ready-meta-reconcile.test.tsx
1 // Run: tsx src/__tests__/ready-meta-reconcile.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(ms = 0): Promise<void> {
34 return new Promise((resolve) => setTimeout(resolve, ms));
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 < 100; attempt += 1) {
49 await act(async () => {
50 await flushPromises(25);
51 });
52 if (predicate()) return;
53 }
54 throw new Error(`timed out waiting for ${label}`);
55 }
56
57 function tabMeta(id: string, ready: boolean, active: boolean): TabMeta {
58 return {
59 id,
60 scope: "project",
61 workspaceRoot: "/repo",
62 workspaceName: "repo",
63 workspacePath: "/repo",
64 gitBranch: "main",
65 topicId: `topic-${id}`,
66 topicTitle: id,
67 sessionPath: `/repo/sessions/${id}.jsonl`,
68 label: "model",
69 ready,
70 running: false,
71 mode: "normal",
72 toolApprovalMode: "ask",
73 tokenMode: "full",
74 active,
75 cwd: "/repo",
76 };
77 }
78
79 function meta(tabId: string, 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 sessionPath: `/repo/sessions/${tabId}.jsonl`,
89 gitBranch: "main",
90 autoApproveTools: false,
91 bypass: false,
92 collaborationMode: "normal",
93 toolApprovalMode: "ask",
94 tokenMode: "full",
95 goal: "",
96 goalStatus: "stopped",
97 };
98 }
99
100 console.log("\nready meta reconcile");
101
102 const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
103 pretendToBeVisual: true,
104 url: "http://localhost/",
105 });
106 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
107 globalThis.window = dom.window as unknown as Window & typeof globalThis;
108 globalThis.document = dom.window.document;
109 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
110 globalThis.Node = dom.window.Node;
111 globalThis.HTMLElement = dom.window.HTMLElement;
112 globalThis.Event = dom.window.Event;
113 globalThis.CustomEvent = dom.window.CustomEvent;
114 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
115 globalThis.MouseEvent = dom.window.MouseEvent;
116 globalThis.localStorage = dom.window.localStorage;
117 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
118 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
119
120 const context: ContextInfo = { used: 0, window: 100, sessionTokens: 0 };
121 const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] };
122 const balance: BalanceInfo = { available: false, display: "" };
123 const jobs: JobView[] = [];
124 const checkpoints: CheckpointMeta[] = [];
125 const historyGate = deferred<HistoryMessage[]>();
126 let backendReady = false;
127 let listTabsCalls = 0;
128 let historyCalls = 0;
129 let metaCalls = 0;
130 let approvalModeCalls = 0;
131 const metaTabIds: string[] = [];
132
133 installDesktopHostStub(({
134 main: {
135 App: {
136 ListTabs: async () => {
137 listTabsCalls += 1;
138 return [
139 tabMeta("tab-ready", backendReady, true),
140 tabMeta("tab-inactive", true, false),
141 ];
142 },
143 MetaForTab: async (tabId: string) => {
144 metaCalls += 1;
145 metaTabIds.push(tabId);
146 return meta(tabId, tabId === "tab-ready" ? backendReady : true);
147 },
148 ContextUsageForTab: async () => context,
149 EffortForTab: async () => effort,
150 BalanceForTab: async () => balance,
151 JobsForTab: async () => jobs,
152 CheckpointsForTab: async () => checkpoints,
153 ForkTargetsForTab: async () => ({ targets: [], verifiable: false }),
154 HistoryForTab: async () => historyGate.promise,
155 HistoryPageForTab: async (tabId: string) => {
156 historyCalls += 1;
157 const messages = await historyGate.promise;
158 return {
159 messages,
160 startTurn: 0,
161 endTurn: messages.filter((message) => message.role === "user").length,
162 totalTurns: messages.filter((message) => message.role === "user").length,
163 hasOlder: false,
164 };
165 },
166 HistorySliceForTab: async (tabId: string, req: HistorySliceRequest) => {
167 historyCalls += 1;
168 return historySliceFromMessages(tabId, await historyGate.promise, req);
169 },
170 HistoryCheckpointTurnsForTab: async () => [],
171 ReplayPendingPrompts: async () => {},
172 SetToolApprovalModeForTab: async () => {
173 approvalModeCalls += 1;
174 },
175 } as Partial<AppBindings> as AppBindings,
176 },
177 }).main.App);
178
179 type Controller = ReturnType<typeof useController>;
180 let controller: Controller | undefined;
181
182 function Probe() {
183 controller = useController();
184 return null;
185 }
186
187 const rootEl = document.getElementById("root");
188 if (!rootEl) throw new Error("missing root");
189 const root = createRoot(rootEl);
190
191 await act(async () => {
192 root.render(<Probe />);
193 await flushPromises();
194 });
195
196 await waitFor("initial not-ready metadata", () => controller?.activeTabId === "tab-ready" && controller.state.meta?.ready === false);
197 eq(historyCalls, 1, "startup begins one active-tab history hydration");
198 eq(listTabsCalls, 1, "startup fetches the active tab once");
199
200 backendReady = true;
201 await waitFor("ready metadata is reconciled without a ready event", () => controller?.state.meta?.ready === true);
202
203 ok(metaCalls >= 1, "active tab metadata is polled after a missed ready event");
204 ok(metaTabIds.length > 0 && metaTabIds.every((tabId) => tabId === "tab-ready"), "ready polling is limited to the active tab");
205 eq(listTabsCalls, 1, "ready polling does not re-list or activate tabs");
206 eq(historyCalls, 1, "ready polling does not start another history hydration");
207 eq(approvalModeCalls, 0, "ready polling does not rely on approval-mode changes");
208
209 await act(async () => {
210 historyGate.resolve([{ role: "user", content: "hello" }]);
211 await historyGate.promise;
212 await flushPromises();
213 });
214 await waitFor("history finishes", () => controller?.state.hydrating === false);
215 ok(controller?.state.items.some((item) => item.kind === "user" && item.text === "hello") ?? false, "history still hydrates after ready reconciliation");
216
217 await act(async () => {
218 root.unmount();
219 });
220 dom.window.close();
221
222 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
223 if (failed > 0) process.exit(1);
224
224 lines Plain Text