tenseleyflow/claudex / d8daf2f

Browse files

tests: store pty lifecycle + parallel-threads regression guard

Authored by espadonne
SHA
d8daf2fed2444c95a92356257f629115cad22d8a
Parents
d4c1f7d
Tree
7c3b2e3

1 changed file

StatusFile+-
M src/lib/store/sessions.test.ts 204 1
src/lib/store/sessions.test.tsmodified
@@ -6,11 +6,14 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
66
 // events into it on demand.
77
 let chatEventCb: ((ev: any) => void) | null = null;
88
 let sessionsChangedCb: ((ev: any) => void) | null = null;
9
+let ptyExitCb: ((ev: any) => void) | null = null;
910
 const startTurnMock = vi.fn().mockResolvedValue(undefined);
1011
 const cancelTurnMock = vi.fn().mockResolvedValue(undefined);
1112
 const readSessionMock = vi.fn();
1213
 const listProjectsMock = vi.fn().mockResolvedValue([]);
1314
 const rescanMock = vi.fn().mockResolvedValue([]);
15
+const closePtyMock = vi.fn().mockResolvedValue(undefined);
16
+const listPtysMock = vi.fn().mockResolvedValue([]);
1417
 
1518
 vi.mock("@/lib/ipc/client", () => ({
1619
   listProjects: (...args: unknown[]) => listProjectsMock(...args),
@@ -28,10 +31,21 @@ vi.mock("@/lib/ipc/client", () => ({
2831
     chatEventCb = cb;
2932
     return () => {};
3033
   }),
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
+  }),
3145
 }));
3246
 
3347
 import { useSessionStore } from "./sessions";
34
-import type { Message, SessionDetail } from "@/lib/ipc/types";
48
+import type { Message, SessionDetail, SessionSummary } from "@/lib/ipc/types";
3549
 
3650
 function seedSelectedSession(sessionId: string): SessionDetail {
3751
   const detail: SessionDetail = {
@@ -232,3 +246,192 @@ describe("useSessionStore chat lifecycle", () => {
232246
     expect((userMsg as Extract<Message, { kind: "user" }>).text).toBe("hello");
233247
   });
234248
 });
249
+
250
+function makeSessionSummary(
251
+  id: string,
252
+  overrides: Partial<SessionSummary> = {},
253
+): SessionSummary {
254
+  return {
255
+    id,
256
+    projectId: "-enc-test",
257
+    title: `session ${id}`,
258
+    startedAt: null,
259
+    lastActivityAt: null,
260
+    model: null,
261
+    messageCount: 0,
262
+    gitBranch: null,
263
+    version: null,
264
+    slug: null,
265
+    cwd: "/tmp/test",
266
+    customTitle: null,
267
+    entrypoint: "cli",
268
+    source: "disk",
269
+    ...overrides,
270
+  };
271
+}
272
+
273
+function dispatchPtyExit(ev: any) {
274
+  if (!ptyExitCb) throw new Error("pty exit subscriber not attached");
275
+  ptyExitCb(ev);
276
+}
277
+
278
+describe("useSessionStore pty lifecycle", () => {
279
+  beforeAll(async () => {
280
+    await useSessionStore.getState().subscribeToPtyEvents();
281
+  });
282
+
283
+  beforeEach(() => {
284
+    useSessionStore.setState({
285
+      projects: [],
286
+      expandedProjectIds: new Set(),
287
+      selectedSessionId: null,
288
+      detail: null,
289
+      inFlightTurns: new Map(),
290
+      viewerMode: new Map(),
291
+      ptyIds: new Map(),
292
+      ptyInfos: new Map(),
293
+      loading: { projects: false, detail: false },
294
+      error: null,
295
+    });
296
+    closePtyMock.mockClear();
297
+    listPtysMock.mockClear();
298
+    readSessionMock.mockReset();
299
+  });
300
+
301
+  it("toggleViewerMode flips cards → terminal → cards", () => {
302
+    useSessionStore.getState().toggleViewerMode("s-1");
303
+    expect(useSessionStore.getState().viewerMode.get("s-1")).toBe("terminal");
304
+    useSessionStore.getState().toggleViewerMode("s-1");
305
+    expect(useSessionStore.getState().viewerMode.get("s-1")).toBe("cards");
306
+  });
307
+
308
+  it("toggleViewerMode is a no-op for archive sessions", () => {
309
+    const detail: SessionDetail = {
310
+      summary: makeSessionSummary("arc-1", { source: "archive" }),
311
+      messages: [],
312
+    };
313
+    useSessionStore.setState({ detail });
314
+    useSessionStore.getState().setViewerMode("arc-1", "cards");
315
+    useSessionStore.getState().toggleViewerMode("arc-1");
316
+    expect(useSessionStore.getState().viewerMode.get("arc-1")).toBe("cards");
317
+  });
318
+
319
+  it("beginNewSession defaults the new session's mode to terminal", () => {
320
+    useSessionStore.getState().beginNewSession("/tmp/proj", "proj");
321
+    const { selectedSessionId, viewerMode } = useSessionStore.getState();
322
+    expect(selectedSessionId).toMatch(/^pending-/);
323
+    expect(viewerMode.get(selectedSessionId!)).toBe("terminal");
324
+  });
325
+
326
+  it("selectSession defaults a disk session to cards mode", async () => {
327
+    const summary = makeSessionSummary("s-disk");
328
+    readSessionMock.mockResolvedValueOnce({ summary, messages: [] });
329
+    await useSessionStore.getState().selectSession(summary);
330
+    expect(useSessionStore.getState().viewerMode.get("s-disk")).toBe("cards");
331
+  });
332
+
333
+  it("selectSession does NOT kill the previous session's PTY (parallel threads)", async () => {
334
+    // Seed a live PTY bound to session A.
335
+    useSessionStore.getState().registerPty("s-a", {
336
+      ptyId: "pty-a",
337
+      sessionId: "s-a",
338
+      cwd: "/tmp/a",
339
+      startedAt: new Date().toISOString(),
340
+    });
341
+    expect(useSessionStore.getState().ptyIds.get("s-a")).toBe("pty-a");
342
+
343
+    // Now switch to session B.
344
+    const summaryB = makeSessionSummary("s-b", { cwd: "/tmp/b" });
345
+    readSessionMock.mockResolvedValueOnce({
346
+      summary: summaryB,
347
+      messages: [],
348
+    });
349
+    await useSessionStore.getState().selectSession(summaryB);
350
+
351
+    // Session A's PTY must still be registered — this is the
352
+    // codex-parallel-threads regression guard.
353
+    expect(useSessionStore.getState().ptyIds.get("s-a")).toBe("pty-a");
354
+    expect(useSessionStore.getState().selectedSessionId).toBe("s-b");
355
+    expect(closePtyMock).not.toHaveBeenCalled();
356
+  });
357
+
358
+  it("switching away and back preserves the registered pty id", async () => {
359
+    const summaryA = makeSessionSummary("s-keep", { cwd: "/tmp/keep" });
360
+    readSessionMock.mockResolvedValue({ summary: summaryA, messages: [] });
361
+    await useSessionStore.getState().selectSession(summaryA);
362
+    useSessionStore.getState().registerPty("s-keep", {
363
+      ptyId: "pty-keep",
364
+      sessionId: "s-keep",
365
+      cwd: "/tmp/keep",
366
+      startedAt: new Date().toISOString(),
367
+    });
368
+
369
+    const summaryB = makeSessionSummary("s-other", { cwd: "/tmp/other" });
370
+    readSessionMock.mockResolvedValueOnce({
371
+      summary: summaryB,
372
+      messages: [],
373
+    });
374
+    await useSessionStore.getState().selectSession(summaryB);
375
+    readSessionMock.mockResolvedValueOnce({
376
+      summary: summaryA,
377
+      messages: [],
378
+    });
379
+    await useSessionStore.getState().selectSession(summaryA);
380
+    expect(useSessionStore.getState().ptyIds.get("s-keep")).toBe("pty-keep");
381
+  });
382
+
383
+  it("pty:exit clears the ptyIds entry for the exited subprocess", () => {
384
+    useSessionStore.getState().registerPty("s-exit", {
385
+      ptyId: "pty-exit",
386
+      sessionId: "s-exit",
387
+      cwd: "/tmp/exit",
388
+      startedAt: new Date().toISOString(),
389
+    });
390
+    dispatchPtyExit({ ptyId: "pty-exit", exitCode: 0 });
391
+    expect(useSessionStore.getState().ptyIds.has("s-exit")).toBe(false);
392
+    expect(useSessionStore.getState().ptyInfos.has("pty-exit")).toBe(false);
393
+  });
394
+
395
+  it("pty:exit for an unknown ptyId is a harmless no-op", () => {
396
+    useSessionStore.getState().registerPty("s-live", {
397
+      ptyId: "pty-live",
398
+      sessionId: "s-live",
399
+      cwd: "/tmp/live",
400
+      startedAt: new Date().toISOString(),
401
+    });
402
+    dispatchPtyExit({ ptyId: "pty-ghost", exitCode: 0 });
403
+    expect(useSessionStore.getState().ptyIds.get("s-live")).toBe("pty-live");
404
+  });
405
+
406
+  it("closeSessionPty fires the backend command and flips mode to cards", async () => {
407
+    useSessionStore.getState().registerPty("s-close", {
408
+      ptyId: "pty-close",
409
+      sessionId: "s-close",
410
+      cwd: "/tmp/close",
411
+      startedAt: new Date().toISOString(),
412
+    });
413
+    useSessionStore.getState().setViewerMode("s-close", "terminal");
414
+    await useSessionStore.getState().closeSessionPty("s-close");
415
+    expect(closePtyMock).toHaveBeenCalledWith("pty-close");
416
+    expect(useSessionStore.getState().ptyIds.has("s-close")).toBe(false);
417
+    expect(useSessionStore.getState().ptyInfos.has("pty-close")).toBe(false);
418
+    expect(useSessionStore.getState().viewerMode.get("s-close")).toBe("cards");
419
+  });
420
+
421
+  it("closeSessionPty is a no-op when the session has no live pty", async () => {
422
+    await useSessionStore.getState().closeSessionPty("s-none");
423
+    expect(closePtyMock).not.toHaveBeenCalled();
424
+  });
425
+
426
+  it("registerPty populates both ptyIds and ptyInfos", () => {
427
+    useSessionStore.getState().registerPty("s-reg", {
428
+      ptyId: "pty-reg",
429
+      sessionId: "s-reg",
430
+      cwd: "/tmp/reg",
431
+      startedAt: "2026-04-10T12:00:00.000Z",
432
+    });
433
+    const state = useSessionStore.getState();
434
+    expect(state.ptyIds.get("s-reg")).toBe("pty-reg");
435
+    expect(state.ptyInfos.get("pty-reg")?.cwd).toBe("/tmp/reg");
436
+  });
437
+});