返回 DeepSeek-Reasonix
terminal-store.test.ts
根目录 / desktop / frontend / src / __tests__ / terminal-store.test.ts
1 import { JSDOM } from "jsdom";
2
3 const dom = new JSDOM("<!doctype html><html><body></body></html>");
4 const previousWindow = globalThis.window;
5 globalThis.window = dom.window as unknown as Window & typeof globalThis;
6
7 type Session = {
8 id: string;
9 title: string;
10 shell: string;
11 cwd: string;
12 createdAt: number;
13 running: boolean;
14 };
15 type Workspace = { available: boolean; readOnly: boolean; sessions: Session[]; shells: { id: string; label: string }[] };
16 const pending = new Map<string, (value: Workspace) => void>();
17 const pendingReject = new Map<string, (error: Error) => void>();
18 const createPending: Array<(value: Session) => void> = [];
19 const createReject: Array<(error: Error) => void> = [];
20 let calls = 0;
21 let closeError: Error | null = null;
22 let writeError: Error | null = null;
23 const fakeApp = {
24 TerminalWorkspaceForTab(tabId: string) {
25 calls += 1;
26 return new Promise<Workspace>((resolve, reject) => {
27 pending.set(tabId, resolve);
28 pendingReject.set(tabId, reject);
29 });
30 },
31 CreateTerminalForTab() {
32 return new Promise<Session>((resolve, reject) => {
33 createPending.push(resolve);
34 createReject.push(reject);
35 });
36 },
37 async CloseTerminalForTab() {
38 if (closeError) throw closeError;
39 },
40 async WriteTerminalForTab() {
41 if (writeError) throw writeError;
42 },
43 };
44 const { installDesktopHostStub } = await import("./desktopHostStub");
45 const hostStub = installDesktopHostStub(fakeApp);
46
47 const { resetTerminalStoreForTests, useTerminalStore } = await import("../store/terminal");
48 resetTerminalStoreForTests();
49
50 const oldRequest = useTerminalStore.getState().syncWorkspace("old-tab");
51 const newRequest = useTerminalStore.getState().syncWorkspace("new-tab");
52 pending.get("new-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
53 await newRequest;
54 pending.get("old-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
55 await oldRequest;
56 if (useTerminalStore.getState().tabId !== "new-tab") throw new Error("stale workspace response replaced the selected tab");
57 if (calls !== 2) throw new Error(`expected two workspace calls, got ${calls}`);
58
59 resetTerminalStoreForTests();
60 calls = 0;
61 const shared = useTerminalStore.getState().ensureReady("same-tab");
62 const sharedAgain = useTerminalStore.getState().ensureReady("same-tab");
63 pending.get("same-tab")?.({ available: true, readOnly: false, sessions: [], shells: [] });
64 await Promise.all([shared, sharedAgain]);
65 if (calls !== 1) throw new Error(`ensureReady duplicated the first request: ${calls}`);
66 process.stdout.write("PASS stale responses and first-ready deduplication\n");
67
68 resetTerminalStoreForTests();
69 calls = 0;
70 createPending.length = 0;
71 const firstOpenCreate = useTerminalStore.getState().createSession("first-open");
72 await Promise.resolve();
73 const panelReady = useTerminalStore.getState().syncWorkspace("first-open");
74 await Promise.resolve();
75 if (calls !== 1) throw new Error(`panel readiness duplicated the first-open workspace request: ${calls}`);
76 pending.get("first-open")?.({
77 available: true,
78 readOnly: false,
79 sessions: [],
80 shells: [{ id: "default", label: "Default shell" }],
81 });
82 await panelReady;
83 await Promise.resolve();
84 if (createPending.length !== 1) throw new Error(`first-open create did not reach the backend: ${createPending.length}`);
85 const firstOpenSession: Session = {
86 id: "first-open-session",
87 title: "default",
88 shell: "default",
89 cwd: ".",
90 createdAt: 1,
91 running: true,
92 };
93 createPending[0]?.(firstOpenSession);
94 const createdOnFirstOpen = await firstOpenCreate;
95 if (createdOnFirstOpen?.id !== firstOpenSession.id) throw new Error("first-open create was discarded");
96 process.stdout.write("PASS first-open panel readiness shares the pending workspace request\n");
97
98 resetTerminalStoreForTests();
99 calls = 0;
100 const staleCapability = useTerminalStore.getState().syncWorkspace("capability-refresh");
101 await Promise.resolve();
102 const resolveStaleCapability = pending.get("capability-refresh");
103 const latestCapability = useTerminalStore.getState().syncWorkspace("capability-refresh", true);
104 await Promise.resolve();
105 if (calls !== 2) throw new Error(`forced capability refresh did not start a new request: ${calls}`);
106 pending.get("capability-refresh")?.({
107 available: true,
108 readOnly: false,
109 sessions: [],
110 shells: [{ id: "default", label: "Default shell" }],
111 });
112 await latestCapability;
113 resolveStaleCapability?.({
114 available: true,
115 readOnly: true,
116 sessions: [],
117 shells: [{ id: "default", label: "Default shell" }],
118 });
119 await staleCapability;
120 if (useTerminalStore.getState().workspace?.readOnly) {
121 throw new Error("stale read-only capability replaced the forced writable refresh");
122 }
123 process.stdout.write("PASS forced capability refresh supersedes an in-flight same-tab request\n");
124
125 resetTerminalStoreForTests();
126 calls = 0;
127 const paintedWorkspace: Workspace = {
128 available: true,
129 readOnly: false,
130 sessions: [{
131 id: "painted",
132 title: "zsh",
133 shell: "default",
134 cwd: ".",
135 createdAt: 1,
136 running: true,
137 }],
138 shells: [{ id: "default", label: "Default shell" }],
139 };
140 useTerminalStore.setState({
141 tabId: "warm-tab",
142 workspace: paintedWorkspace,
143 loading: false,
144 activeSessionId: "painted",
145 });
146 const warmRefresh = useTerminalStore.getState().syncWorkspace("warm-tab");
147 await Promise.resolve();
148 if (useTerminalStore.getState().workspace?.sessions[0]?.id !== "painted") {
149 throw new Error("same-tab refresh cleared the painted workspace before the response landed");
150 }
151 if (useTerminalStore.getState().loading !== true) {
152 throw new Error("same-tab refresh should still mark loading while the request is in flight");
153 }
154 pending.get("warm-tab")?.(paintedWorkspace);
155 await warmRefresh;
156 if (calls !== 1) throw new Error(`same-tab refresh expected one workspace call, got ${calls}`);
157 process.stdout.write("PASS same-tab refresh keeps the painted workspace warm\n");
158
159 resetTerminalStoreForTests();
160 createPending.length = 0;
161 const readyWorkspace: Workspace = {
162 available: true,
163 readOnly: false,
164 sessions: [],
165 shells: [{ id: "default", label: "Default shell" }],
166 };
167 useTerminalStore.setState({
168 tabId: "create-tab",
169 workspace: readyWorkspace,
170 loading: false,
171 activeSessionId: null,
172 });
173 const firstCreate = useTerminalStore.getState().createSession("create-tab");
174 const secondCreate = useTerminalStore.getState().createSession("create-tab");
175 await Promise.resolve();
176 await Promise.resolve();
177 if (createPending.length !== 2) throw new Error(`expected two create calls, got ${createPending.length}`);
178 const firstSession: Session = {
179 id: "first",
180 title: "first",
181 shell: "default",
182 cwd: ".",
183 createdAt: 1,
184 running: true,
185 };
186 const secondSession: Session = {
187 id: "second",
188 title: "second",
189 shell: "default",
190 cwd: ".",
191 createdAt: 2,
192 running: true,
193 };
194 createPending[1]?.(secondSession);
195 await secondCreate;
196 createPending[0]?.(firstSession);
197 await firstCreate;
198 const sessionIDs = useTerminalStore.getState().workspace?.sessions.map((session) => session.id).sort();
199 if (JSON.stringify(sessionIDs) !== JSON.stringify(["first", "second"])) {
200 throw new Error(`concurrent creates lost a session: ${JSON.stringify(sessionIDs)}`);
201 }
202 process.stdout.write("PASS concurrent terminal creates merge into the latest workspace state\n");
203
204 resetTerminalStoreForTests();
205 const failedSync = useTerminalStore.getState().syncWorkspace("sync-error");
206 pendingReject.get("sync-error")?.(new Error("workspace unavailable"));
207 await failedSync.catch(() => {});
208 if (useTerminalStore.getState().error !== "workspace unavailable") {
209 throw new Error(`workspace sync error was not exposed: ${useTerminalStore.getState().error}`);
210 }
211 if (useTerminalStore.getState().loading || useTerminalStore.getState().workspace !== null) {
212 throw new Error("failed workspace sync left a loading or partial workspace state");
213 }
214 process.stdout.write("PASS workspace sync failures remain visible and retryable\n");
215
216 resetTerminalStoreForTests();
217 createPending.length = 0;
218 createReject.length = 0;
219 useTerminalStore.setState({
220 tabId: "create-error",
221 workspace: readyWorkspace,
222 loading: false,
223 activeSessionId: null,
224 });
225 const failedCreate = useTerminalStore.getState().createSession("create-error");
226 await Promise.resolve();
227 createReject[0]?.(new Error("shell failed to start"));
228 await failedCreate.catch(() => {});
229 if (useTerminalStore.getState().error !== "shell failed to start") {
230 throw new Error(`terminal create error was not exposed: ${useTerminalStore.getState().error}`);
231 }
232 if (useTerminalStore.getState().workspace?.sessions.length !== 0) {
233 throw new Error("failed terminal create inserted a phantom session");
234 }
235 process.stdout.write("PASS terminal create failures do not insert phantom sessions\n");
236
237 resetTerminalStoreForTests();
238 closeError = new Error("terminal did not close");
239 useTerminalStore.setState({
240 tabId: "close-error",
241 workspace: { ...readyWorkspace, sessions: [firstSession] },
242 loading: false,
243 activeSessionId: firstSession.id,
244 });
245 await useTerminalStore.getState().closeSession("close-error", firstSession.id).catch(() => {});
246 if (useTerminalStore.getState().error !== "terminal did not close") {
247 throw new Error(`terminal close error was not exposed: ${useTerminalStore.getState().error}`);
248 }
249 if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) {
250 throw new Error("failed terminal close removed the live session from the workspace");
251 }
252 useTerminalStore.getState().clearError();
253 if (useTerminalStore.getState().error !== null) throw new Error("terminal error dismissal did not clear the error");
254 closeError = null;
255 process.stdout.write("PASS terminal close failures preserve the session and can be dismissed\n");
256
257 writeError = new Error("terminal input failed");
258 await useTerminalStore.getState().write("close-error", firstSession.id, "echo test").catch(() => {});
259 if (useTerminalStore.getState().error !== "terminal input failed") {
260 throw new Error(`terminal write error was not exposed: ${useTerminalStore.getState().error}`);
261 }
262 writeError = null;
263 process.stdout.write("PASS terminal write failures remain visible to the user\n");
264
265 const { startTerminalEventBridge } = await import("../lib/terminalEvents");
266 const stopEventBridge = startTerminalEventBridge();
267 useTerminalStore.getState().clearError();
268 hostStub.emit("desktop:resync", { generation: "g1", reason: "gap" });
269 if (!useTerminalStore.getState().error) throw new Error("an event gap left terminal output falsely complete");
270 if (useTerminalStore.getState().workspace?.sessions[0]?.id !== firstSession.id) throw new Error("gap handling removed the existing terminal");
271 stopEventBridge();
272 process.stdout.write("PASS terminal event gaps preserve the view and expose incomplete output\n");
273
274 if (previousWindow) globalThis.window = previousWindow;
275 else delete (globalThis as { window?: unknown }).window;
276
276 lines TYPESCRIPT