TypeScript · 15301 bytes Raw Blame History
1 import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2
3 // Mock the IPC client module BEFORE importing the store. The store
4 // eagerly reads `onChatEvent` and friends, so we need fakes in place
5 // first. Each fake collects the callback so tests can dispatch fake
6 // events into it on demand.
7 let chatEventCb: ((ev: any) => void) | null = null;
8 let sessionsChangedCb: ((ev: any) => void) | null = null;
9 let ptyExitCb: ((ev: any) => void) | null = null;
10 const startTurnMock = vi.fn().mockResolvedValue(undefined);
11 const cancelTurnMock = vi.fn().mockResolvedValue(undefined);
12 const readSessionMock = vi.fn();
13 const listProjectsMock = vi.fn().mockResolvedValue([]);
14 const rescanMock = vi.fn().mockResolvedValue([]);
15 const closePtyMock = vi.fn().mockResolvedValue(undefined);
16 const listPtysMock = vi.fn().mockResolvedValue([]);
17
18 vi.mock("@/lib/ipc/client", () => ({
19 listProjects: (...args: unknown[]) => listProjectsMock(...args),
20 listSessions: vi.fn(),
21 readSession: (...args: unknown[]) => readSessionMock(...args),
22 rescan: (...args: unknown[]) => rescanMock(...args),
23 startTurn: (...args: unknown[]) => startTurnMock(...args),
24 cancelTurn: (...args: unknown[]) => cancelTurnMock(...args),
25 listActiveTurns: vi.fn().mockResolvedValue([]),
26 onSessionsChanged: vi.fn().mockImplementation(async (cb) => {
27 sessionsChangedCb = cb;
28 return () => {};
29 }),
30 onChatEvent: vi.fn().mockImplementation(async (cb) => {
31 chatEventCb = cb;
32 return () => {};
33 }),
34 spawnPty: vi.fn().mockResolvedValue(undefined),
35 writePty: vi.fn().mockResolvedValue(undefined),
36 resizePty: vi.fn().mockResolvedValue(undefined),
37 closePty: (...args: unknown[]) => closePtyMock(...args),
38 getPtyBuffer: vi.fn().mockResolvedValue(""),
39 listPtys: (...args: unknown[]) => listPtysMock(...args),
40 onPtyData: vi.fn().mockResolvedValue(() => {}),
41 onPtyExit: vi.fn().mockImplementation(async (cb) => {
42 ptyExitCb = cb;
43 return () => {};
44 }),
45 }));
46
47 import { useSessionStore } from "./sessions";
48 import type { Message, SessionDetail, SessionSummary } from "@/lib/ipc/types";
49
50 function seedSelectedSession(sessionId: string): SessionDetail {
51 const detail: SessionDetail = {
52 summary: {
53 id: sessionId,
54 projectId: "-enc-test",
55 title: "test",
56 startedAt: null,
57 lastActivityAt: null,
58 model: null,
59 messageCount: 0,
60 promptCount: 0,
61 gitBranch: null,
62 version: null,
63 slug: null,
64 cwd: "/tmp/test",
65 customTitle: null,
66 entrypoint: "cli",
67 source: "disk",
68 },
69 messages: [],
70 };
71 useSessionStore.setState({
72 selectedSessionId: sessionId,
73 detail,
74 inFlightTurns: new Map(),
75 });
76 return detail;
77 }
78
79 function dispatchChat(ev: any) {
80 if (!chatEventCb) throw new Error("chat subscriber not attached");
81 chatEventCb(ev);
82 }
83
84 describe("useSessionStore chat lifecycle", () => {
85 // subscribeToChatEvents is module-level attach-once; call it once
86 // for the whole file. The mock captures chatEventCb and we
87 // dispatch into it manually from each test.
88 beforeAll(async () => {
89 await useSessionStore.getState().subscribeToChatEvents();
90 });
91
92 beforeEach(() => {
93 // Reset only store state + mock call counts per test. Do NOT
94 // clear chatEventCb — that's captured by the one-time subscribe.
95 useSessionStore.setState({
96 projects: [],
97 expandedProjectIds: new Set(),
98 selectedSessionId: null,
99 detail: null,
100 inFlightTurns: new Map(),
101 loading: { projects: false, detail: false },
102 error: null,
103 });
104 startTurnMock.mockClear();
105 cancelTurnMock.mockClear();
106 readSessionMock.mockClear();
107 });
108 // Silence unused-var warning from strictly-typed test scaffolding.
109 void sessionsChangedCb;
110
111 it("turn_started inserts an in-flight turn", () => {
112 dispatchChat({
113 kind: "turn_started",
114 turnId: "t1",
115 resumeSessionId: "s-abc",
116 newSessionId: null,
117 });
118 const turn = useSessionStore.getState().inFlightTurns.get("t1");
119 expect(turn).toBeDefined();
120 expect(turn?.status).toBe("spawning");
121 expect(turn?.sessionId).toBe("s-abc");
122 });
123
124 it("session_bound flips status to streaming", () => {
125 dispatchChat({
126 kind: "turn_started",
127 turnId: "t1",
128 resumeSessionId: "s-abc",
129 newSessionId: null,
130 });
131 dispatchChat({
132 kind: "session_bound",
133 turnId: "t1",
134 sessionId: "s-abc",
135 });
136 const turn = useSessionStore.getState().inFlightTurns.get("t1");
137 expect(turn?.status).toBe("streaming");
138 });
139
140 it("message events merge assistant partials by id", () => {
141 seedSelectedSession("s-merge");
142 dispatchChat({
143 kind: "turn_started",
144 turnId: "t1",
145 resumeSessionId: "s-merge",
146 newSessionId: null,
147 });
148
149 const partial1: Message = {
150 kind: "assistant",
151 id: "msg1",
152 at: "2026-04-11T00:00:00.000Z",
153 model: "claude-opus-4-6",
154 blocks: [{ type: "text", text: "Hello " }],
155 stopReason: null,
156 usage: null,
157 status: "streaming",
158 };
159 const partial2: Message = {
160 ...partial1,
161 blocks: [{ type: "text", text: "Hello world!" }],
162 };
163 dispatchChat({ kind: "message", turnId: "t1", message: partial1 });
164 dispatchChat({ kind: "message", turnId: "t1", message: partial2 });
165
166 const messages = useSessionStore.getState().detail!.messages;
167 // Two dispatches should merge into one message.
168 const assistants = messages.filter((m) => m.kind === "assistant");
169 expect(assistants.length).toBe(1);
170 const blocks = (assistants[0] as Extract<Message, { kind: "assistant" }>)
171 .blocks;
172 expect(blocks[0]).toEqual({ type: "text", text: "Hello world!" });
173 });
174
175 it("turn_failed flips last assistant status to error", () => {
176 seedSelectedSession("s-fail");
177 const assistant: Message = {
178 kind: "assistant",
179 id: "msg1",
180 at: "2026-04-11T00:00:00.000Z",
181 model: "claude-opus-4-6",
182 blocks: [{ type: "text", text: "partial" }],
183 stopReason: null,
184 usage: null,
185 status: "streaming",
186 };
187 useSessionStore.setState((s) => ({
188 detail: { ...s.detail!, messages: [assistant] },
189 }));
190 dispatchChat({
191 kind: "turn_started",
192 turnId: "t1",
193 resumeSessionId: "s-fail",
194 newSessionId: null,
195 });
196 dispatchChat({
197 kind: "turn_failed",
198 turnId: "t1",
199 reason: "permission denied",
200 });
201 const last = useSessionStore.getState().detail!.messages.at(-1)!;
202 expect(last.kind).toBe("assistant");
203 expect((last as Extract<Message, { kind: "assistant" }>).status).toBe(
204 "error",
205 );
206 const turn = useSessionStore.getState().inFlightTurns.get("t1");
207 expect(turn?.status).toBe("failed");
208 expect(turn?.error).toBe("permission denied");
209 });
210
211 it("cancelTurn forwards to the backend command", async () => {
212 await useSessionStore.getState().cancelTurn("t1");
213 expect(cancelTurnMock).toHaveBeenCalledWith("t1");
214 });
215
216 it("beginNewSession creates a pending detail the user can type into", () => {
217 useSessionStore.getState().beginNewSession("/tmp/proj", "proj");
218 const { selectedSessionId, detail } = useSessionStore.getState();
219 expect(selectedSessionId).toMatch(/^pending-/);
220 expect(detail?.summary.cwd).toBe("/tmp/proj");
221 expect(detail?.messages.length).toBe(0);
222 expect(detail?.summary.title).toContain("proj");
223 });
224
225 it("startTurn fires with resumeSessionId for an existing disk session", async () => {
226 seedSelectedSession("s-resume");
227 await useSessionStore.getState().startTurn("hi");
228 expect(startTurnMock).toHaveBeenCalledTimes(1);
229 const arg = startTurnMock.mock.calls[0][0];
230 expect(arg.resumeSessionId).toBe("s-resume");
231 expect(arg.newSessionId).toBeNull();
232 expect(arg.prompt).toBe("hi");
233 });
234
235 it("startTurn extracts newSessionId from a pending-prefixed id", async () => {
236 useSessionStore.getState().beginNewSession("/tmp/proj", "proj");
237 await useSessionStore.getState().startTurn("hello");
238 const arg = startTurnMock.mock.calls[0][0];
239 expect(arg.resumeSessionId).toBeNull();
240 expect(arg.newSessionId).toMatch(
241 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
242 );
243 // The optimistic user message was appended.
244 const messages = useSessionStore.getState().detail!.messages;
245 const userMsg = messages.find((m) => m.kind === "user");
246 expect(userMsg).toBeDefined();
247 expect((userMsg as Extract<Message, { kind: "user" }>).text).toBe("hello");
248 });
249 });
250
251 function makeSessionSummary(
252 id: string,
253 overrides: Partial<SessionSummary> = {},
254 ): SessionSummary {
255 return {
256 id,
257 projectId: "-enc-test",
258 title: `session ${id}`,
259 startedAt: null,
260 lastActivityAt: null,
261 model: null,
262 messageCount: 0,
263 promptCount: 0,
264 gitBranch: null,
265 version: null,
266 slug: null,
267 cwd: "/tmp/test",
268 customTitle: null,
269 entrypoint: "cli",
270 source: "disk",
271 ...overrides,
272 };
273 }
274
275 function dispatchPtyExit(ev: any) {
276 if (!ptyExitCb) throw new Error("pty exit subscriber not attached");
277 ptyExitCb(ev);
278 }
279
280 describe("useSessionStore pty lifecycle", () => {
281 beforeAll(async () => {
282 await useSessionStore.getState().subscribeToPtyEvents();
283 });
284
285 beforeEach(() => {
286 useSessionStore.setState({
287 projects: [],
288 expandedProjectIds: new Set(),
289 selectedSessionId: null,
290 detail: null,
291 inFlightTurns: new Map(),
292 viewerMode: new Map(),
293 ptyIds: new Map(),
294 ptyInfos: new Map(),
295 loading: { projects: false, detail: false },
296 error: null,
297 });
298 closePtyMock.mockClear();
299 listPtysMock.mockClear();
300 readSessionMock.mockReset();
301 });
302
303 it("toggleViewerMode flips cards → terminal → cards", () => {
304 useSessionStore.getState().toggleViewerMode("s-1");
305 expect(useSessionStore.getState().viewerMode.get("s-1")).toBe("terminal");
306 useSessionStore.getState().toggleViewerMode("s-1");
307 expect(useSessionStore.getState().viewerMode.get("s-1")).toBe("cards");
308 });
309
310 it("toggleViewerMode is a no-op for archive sessions", () => {
311 const detail: SessionDetail = {
312 summary: makeSessionSummary("arc-1", { source: "archive" }),
313 messages: [],
314 };
315 useSessionStore.setState({ detail });
316 useSessionStore.getState().setViewerMode("arc-1", "cards");
317 useSessionStore.getState().toggleViewerMode("arc-1");
318 expect(useSessionStore.getState().viewerMode.get("arc-1")).toBe("cards");
319 });
320
321 it("beginNewSession defaults the new session's mode to terminal", () => {
322 useSessionStore.getState().beginNewSession("/tmp/proj", "proj");
323 const { selectedSessionId, viewerMode } = useSessionStore.getState();
324 expect(selectedSessionId).toMatch(/^pending-/);
325 expect(viewerMode.get(selectedSessionId!)).toBe("terminal");
326 });
327
328 it("selectSession defaults a disk session to cards mode", async () => {
329 const summary = makeSessionSummary("s-disk");
330 readSessionMock.mockResolvedValueOnce({ summary, messages: [] });
331 await useSessionStore.getState().selectSession(summary);
332 expect(useSessionStore.getState().viewerMode.get("s-disk")).toBe("cards");
333 });
334
335 it("selectSession does NOT kill the previous session's PTY (parallel threads)", async () => {
336 // Seed a live PTY bound to session A.
337 useSessionStore.getState().registerPty("s-a", {
338 ptyId: "pty-a",
339 sessionId: "s-a",
340 cwd: "/tmp/a",
341 startedAt: new Date().toISOString(),
342 });
343 expect(useSessionStore.getState().ptyIds.get("s-a")).toBe("pty-a");
344
345 // Now switch to session B.
346 const summaryB = makeSessionSummary("s-b", { cwd: "/tmp/b" });
347 readSessionMock.mockResolvedValueOnce({
348 summary: summaryB,
349 messages: [],
350 });
351 await useSessionStore.getState().selectSession(summaryB);
352
353 // Session A's PTY must still be registered — this is the
354 // codex-parallel-threads regression guard.
355 expect(useSessionStore.getState().ptyIds.get("s-a")).toBe("pty-a");
356 expect(useSessionStore.getState().selectedSessionId).toBe("s-b");
357 expect(closePtyMock).not.toHaveBeenCalled();
358 });
359
360 it("switching away and back preserves the registered pty id", async () => {
361 const summaryA = makeSessionSummary("s-keep", { cwd: "/tmp/keep" });
362 readSessionMock.mockResolvedValue({ summary: summaryA, messages: [] });
363 await useSessionStore.getState().selectSession(summaryA);
364 useSessionStore.getState().registerPty("s-keep", {
365 ptyId: "pty-keep",
366 sessionId: "s-keep",
367 cwd: "/tmp/keep",
368 startedAt: new Date().toISOString(),
369 });
370
371 const summaryB = makeSessionSummary("s-other", { cwd: "/tmp/other" });
372 readSessionMock.mockResolvedValueOnce({
373 summary: summaryB,
374 messages: [],
375 });
376 await useSessionStore.getState().selectSession(summaryB);
377 readSessionMock.mockResolvedValueOnce({
378 summary: summaryA,
379 messages: [],
380 });
381 await useSessionStore.getState().selectSession(summaryA);
382 expect(useSessionStore.getState().ptyIds.get("s-keep")).toBe("pty-keep");
383 });
384
385 it("pty:exit clears the ptyIds entry for the exited subprocess", () => {
386 useSessionStore.getState().registerPty("s-exit", {
387 ptyId: "pty-exit",
388 sessionId: "s-exit",
389 cwd: "/tmp/exit",
390 startedAt: new Date().toISOString(),
391 });
392 dispatchPtyExit({ ptyId: "pty-exit", exitCode: 0 });
393 expect(useSessionStore.getState().ptyIds.has("s-exit")).toBe(false);
394 expect(useSessionStore.getState().ptyInfos.has("pty-exit")).toBe(false);
395 });
396
397 it("pty:exit for an unknown ptyId is a harmless no-op", () => {
398 useSessionStore.getState().registerPty("s-live", {
399 ptyId: "pty-live",
400 sessionId: "s-live",
401 cwd: "/tmp/live",
402 startedAt: new Date().toISOString(),
403 });
404 dispatchPtyExit({ ptyId: "pty-ghost", exitCode: 0 });
405 expect(useSessionStore.getState().ptyIds.get("s-live")).toBe("pty-live");
406 });
407
408 it("closeSessionPty fires the backend command and flips mode to cards", async () => {
409 useSessionStore.getState().registerPty("s-close", {
410 ptyId: "pty-close",
411 sessionId: "s-close",
412 cwd: "/tmp/close",
413 startedAt: new Date().toISOString(),
414 });
415 useSessionStore.getState().setViewerMode("s-close", "terminal");
416 await useSessionStore.getState().closeSessionPty("s-close");
417 expect(closePtyMock).toHaveBeenCalledWith("pty-close");
418 expect(useSessionStore.getState().ptyIds.has("s-close")).toBe(false);
419 expect(useSessionStore.getState().ptyInfos.has("pty-close")).toBe(false);
420 expect(useSessionStore.getState().viewerMode.get("s-close")).toBe("cards");
421 });
422
423 it("closeSessionPty is a no-op when the session has no live pty", async () => {
424 await useSessionStore.getState().closeSessionPty("s-none");
425 expect(closePtyMock).not.toHaveBeenCalled();
426 });
427
428 it("registerPty populates both ptyIds and ptyInfos", () => {
429 useSessionStore.getState().registerPty("s-reg", {
430 ptyId: "pty-reg",
431 sessionId: "s-reg",
432 cwd: "/tmp/reg",
433 startedAt: "2026-04-10T12:00:00.000Z",
434 });
435 const state = useSessionStore.getState();
436 expect(state.ptyIds.get("s-reg")).toBe("pty-reg");
437 expect(state.ptyInfos.get("pty-reg")?.cwd).toBe("/tmp/reg");
438 });
439 });