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