1import { describe, expect, it, vi } from "vite-plus/test";
2import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts";
3import { selectRunningSubprocessTerminalIds } from "@t3tools/client-runtime/state/terminal";
4import * as Cause from "effect/Cause";
5import * as Option from "effect/Option";
6import { AsyncResult, AtomRegistry } from "effect/reactivity";
7
8import {
9 attachedTerminalSessionAtom,
10 knownTerminalSessionsAtom,
11 selectKnownTerminalSessions,
12} from "./terminalSessions";
13
14const attachCalls = vi.hoisted(() => [] as unknown[]);
15const metadataAtom = await vi.hoisted(async () => {
16 const { AsyncResult, Atom } = await import("effect/reactivity");
17 return Atom.make<AsyncResult.AsyncResult<ReadonlyArray<TerminalSummary>>>(
18 AsyncResult.initial(true),
19 );
20});
21vi.mock("./terminal", async () => {
22 const { AsyncResult, Atom } = await import("effect/reactivity");
23 return {
24 terminalEnvironment: {
25 attach: (request: unknown) => {
26 attachCalls.push(request);
27 return Atom.make(AsyncResult.initial(true));
28 },
29 metadata: () => metadataAtom,
30 },
31 };
32});
33vi.mock("./session", async () => {
34 const { AsyncResult, Atom } = await import("effect/reactivity");
35 const granted = Atom.make(AsyncResult.success({}));
36 return {
37 environmentSession: { sessionStateAtom: () => granted },
38 sessionHasScope: () => true,
39 useEnvironmentScope: () => true,
40 };
41});
42
43const environmentA = EnvironmentId.make("environment-a");
44const threadA = ThreadId.make("thread-a");
45const threadB = ThreadId.make("thread-b");
46
47function summary(
48 threadId: string,
49 terminalId: string,
50 changes: Partial<TerminalSummary> = {},
51): TerminalSummary {
52 return {
53 threadId,
54 terminalId,
55 cwd: "/repo",
56 worktreePath: null,
57 status: "running",
58 pid: 123,
59 exitCode: null,
60 exitSignal: null,
61 hasRunningSubprocess: true,
62 label: "Terminal",
63 updatedAt: "2026-09-04T00:00:00.000Z",
64 ...changes,
65 };
66}
67
68describe("selectKnownTerminalSessions", () => {
69 it("changes only the affected group and preserves earlier snapshots", () => {
70 const first = summary(threadA, "terminal-1");
71 const second = summary(threadA, "terminal-2");
72 const other = summary(threadB, "terminal-1");
73 const metadata = [first, second, other];
74 const beforeA = selectKnownTerminalSessions(metadata, environmentA, threadA);
75 const beforeB = selectKnownTerminalSessions(metadata, environmentA, threadB);
76 Object.freeze(beforeA);
77 Object.freeze(beforeB);
78 const exited = {
79 ...second,
80 status: "exited" as const,
81 pid: null,
82 exitCode: 0,
83 hasRunningSubprocess: false,
84 label: "Finished",
85 updatedAt: "2026-09-04T00:00:01.000Z",
86 };
87 const next = [first, other, exited];
88 const afterA = selectKnownTerminalSessions(next, environmentA, threadA);
89 expect(selectKnownTerminalSessions(next, environmentA, threadB)).toBe(beforeB);
90 expect(afterA).not.toBe(beforeA);
91 expect(afterA[0]).toBe(beforeA[0]);
92 expect(afterA[1]?.state).toMatchObject({
93 status: "exited",
94 hasRunningSubprocess: false,
95 summary: { label: "Finished", pid: null, exitCode: 0 },
96 });
97 expect(selectRunningSubprocessTerminalIds(afterA)).toEqual(["terminal-1"]);
98 expect(beforeA[1]?.state).toMatchObject({
99 status: "running",
100 hasRunningSubprocess: true,
101 summary: { label: "Terminal", pid: 123 },
102 });
103 expect(selectKnownTerminalSessions(metadata, environmentA, threadA)).toBe(beforeA);
104
105 const renamed = [first, exited, { ...other, label: "Renamed" }];
106 expect(selectKnownTerminalSessions(renamed, environmentA, threadA)).toBe(afterA);
107 expect(
108 selectKnownTerminalSessions(renamed, environmentA, threadB)[0]?.state.summary?.label,
109 ).toBe("Renamed");
110 expect(selectKnownTerminalSessions([other], environmentA, threadA)).toEqual([]);
111 expect(selectKnownTerminalSessions([other], environmentA, threadB)).toBe(beforeB);
112 });
113});
114
115describe("knownTerminalSessionsAtom", () => {
116 const metadataResult = (...terminals: ReadonlyArray<TerminalSummary>) =>
117 AsyncResult.success<ReadonlyArray<TerminalSummary>>(terminals);
118
119 it("notifies only when the thread's own terminals change", () => {
120 const registry = AtomRegistry.make();
121 const sessions = knownTerminalSessionsAtom(environmentA, threadA);
122 expect(registry.get(sessions)).toBeNull();
123
124 const first = summary(threadA, "terminal-1");
125 const other = summary(threadB, "terminal-1");
126 registry.set(metadataAtom, metadataResult(first));
127 const notified: unknown[] = [];
128 const unsubscribe = registry.subscribe(sessions, (value) => notified.push(value));
129 const before = registry.get(sessions);
130
131 registry.set(metadataAtom, metadataResult(first, other));
132 registry.set(metadataAtom, metadataResult(first, { ...other, label: "Renamed" }));
133 expect(notified).toEqual([]);
134 expect(registry.get(sessions)).toBe(before);
135
136 registry.set(metadataAtom, metadataResult({ ...first, label: "Renamed" }, other));
137 expect(notified).toHaveLength(1);
138
139 const previousSuccess = Option.some(metadataResult(first));
140 registry.set(metadataAtom, AsyncResult.failure(Cause.die("unavailable"), { previousSuccess }));
141 expect(registry.get(sessions)).toBeNull();
142 unsubscribe();
143 });
144});
145
146describe("attachedTerminalSessionAtom", () => {
147 it("attaches with the launch input it was keyed by", () => {
148 attachCalls.length = 0;
149 const input = {
150 threadId: threadA,
151 terminalId: "terminal-1",
152 cwd: "/repo",
153 worktreePath: null,
154 env: { T3CODE_PROJECT_ROOT: "/repo" },
155 };
156 const atom = attachedTerminalSessionAtom(environmentA, input);
157 expect(attachedTerminalSessionAtom(environmentA, { ...input })).toBe(atom);
158
159 AtomRegistry.make().get(atom);
160 expect(attachCalls).toEqual([{ environmentId: environmentA, input }]);
161 });
162});