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";
4
5import { selectKnownTerminalSessions } from "./terminalSessions";
6
7vi.mock("./terminal", () => ({ terminalEnvironment: {} }));
8
9const environmentA = EnvironmentId.make("environment-a");
10const environmentB = EnvironmentId.make("environment-b");
11const threadA = ThreadId.make("thread-a");
12const threadB = ThreadId.make("thread-b");
13
14function summary(
15 threadId: string,
16 terminalId: string,
17 changes: Partial<TerminalSummary> = {},
18): TerminalSummary {
19 return {
20 threadId,
21 terminalId,
22 cwd: "/repo",
23 worktreePath: null,
24 status: "running",
25 pid: 123,
26 exitCode: null,
27 exitSignal: null,
28 hasRunningSubprocess: true,
29 label: "Terminal",
30 updatedAt: "2026-09-04T00:00:00.000Z",
31 ...changes,
32 };
33}
34
35describe("selectKnownTerminalSessions", () => {
36 it("preserves numeric terminal order and stable picker ties across threads", () => {
37 const metadata = [
38 summary(threadB, "terminal-2"),
39 summary(threadA, "terminal-10"),
40 summary(threadB, "terminal-1"),
41 summary(threadA, "terminal-1"),
42 summary(threadA, "terminal-01"),
43 ];
44 const all = selectKnownTerminalSessions(metadata, environmentA, null);
45 const selected = selectKnownTerminalSessions(metadata, environmentA, threadB);
46 expect(all.map((session) => `${session.target.threadId}/${session.target.terminalId}`)).toEqual(
47 [
48 "thread-b/terminal-1",
49 "thread-a/terminal-1",
50 "thread-a/terminal-01",
51 "thread-b/terminal-2",
52 "thread-a/terminal-10",
53 ],
54 );
55 expect(selected.map((session) => session.target.terminalId)).toEqual([
56 "terminal-1",
57 "terminal-2",
58 ]);
59 const repeated = [...metadata];
60 expect(selectKnownTerminalSessions(repeated, environmentA, null)).toBe(all);
61 expect(selectKnownTerminalSessions(repeated, environmentA, threadB)).toBe(selected);
62
63 const reordered = metadata.toReversed();
64 expect(selectKnownTerminalSessions(reordered, environmentA, threadB)).toBe(selected);
65 expect(
66 selectKnownTerminalSessions(reordered, environmentA, null).map(
67 (session) => `${session.target.threadId}/${session.target.terminalId}`,
68 ),
69 ).toEqual([
70 "thread-a/terminal-01",
71 "thread-a/terminal-1",
72 "thread-b/terminal-1",
73 "thread-b/terminal-2",
74 "thread-a/terminal-10",
75 ]);
76 });
77
78 it("changes only the affected group and preserves earlier snapshots", () => {
79 const first = summary(threadA, "terminal-1");
80 const second = summary(threadA, "terminal-2");
81 const other = summary(threadB, "terminal-1");
82 const metadata = [first, second, other];
83 const beforeA = selectKnownTerminalSessions(metadata, environmentA, threadA);
84 const beforeB = selectKnownTerminalSessions(metadata, environmentA, threadB);
85 Object.freeze(beforeA);
86 Object.freeze(beforeB);
87 const exited = {
88 ...second,
89 status: "exited" as const,
90 pid: null,
91 exitCode: 0,
92 hasRunningSubprocess: false,
93 label: "Finished",
94 updatedAt: "2026-09-04T00:00:01.000Z",
95 };
96 const next = [first, other, exited];
97 const afterA = selectKnownTerminalSessions(next, environmentA, threadA);
98 expect(selectKnownTerminalSessions(next, environmentA, threadB)).toBe(beforeB);
99 expect(afterA).not.toBe(beforeA);
100 expect(afterA[0]).toBe(beforeA[0]);
101 expect(afterA[1]?.state).toMatchObject({
102 status: "exited",
103 hasRunningSubprocess: false,
104 summary: { label: "Finished", pid: null, exitCode: 0 },
105 });
106 expect(selectRunningSubprocessTerminalIds(afterA)).toEqual(["terminal-1"]);
107 expect(beforeA[1]?.state).toMatchObject({
108 status: "running",
109 hasRunningSubprocess: true,
110 summary: { label: "Terminal", pid: 123 },
111 });
112 expect(selectKnownTerminalSessions(metadata, environmentA, threadA)).toBe(beforeA);
113
114 const renamed = [first, exited, { ...other, label: "Renamed" }];
115 expect(selectKnownTerminalSessions(renamed, environmentA, threadA)).toBe(afterA);
116 expect(
117 selectKnownTerminalSessions(renamed, environmentA, threadB)[0]?.state.summary?.label,
118 ).toBe("Renamed");
119 expect(selectKnownTerminalSessions([other], environmentA, threadA)).toEqual([]);
120 expect(selectKnownTerminalSessions([other], environmentA, threadB)).toBe(beforeB);
121 });
122
123 it("keeps environment targets separate when snapshots share the same objects", () => {
124 const metadata = [summary(threadA, "terminal-1")];
125 const first = selectKnownTerminalSessions(metadata, environmentA, threadA);
126 const second = selectKnownTerminalSessions(metadata, environmentB, threadA);
127 expect(first[0]?.target.environmentId).toBe(environmentA);
128 expect(second[0]?.target.environmentId).toBe(environmentB);
129 expect(first).not.toBe(second);
130 expect(first[0]).not.toBe(second[0]);
131 expect(selectKnownTerminalSessions([...metadata], environmentA, threadA)).toBe(first);
132 expect(selectKnownTerminalSessions([...metadata], environmentB, threadA)).toBe(second);
133 });
134
135 it("returns stable empty results without a connected environment or matching sessions", () => {
136 const metadata = [summary(threadA, "terminal-1")];
137 const empty = selectKnownTerminalSessions(null, environmentA, threadA);
138 expect(selectKnownTerminalSessions(metadata, null, threadA)).toBe(empty);
139 expect(selectKnownTerminalSessions([], environmentA, null)).toBe(empty);
140 expect(selectKnownTerminalSessions(metadata, environmentA, threadB)).toBe(empty);
141 });
142
143 it("does not materialize unrelated raw thread identifiers", () => {
144 const metadata = [summary(" untrimmed-thread ", "terminal-1"), summary(threadA, "terminal-2")];
145 expect(
146 selectKnownTerminalSessions(metadata, environmentA, threadA).map(
147 (session) => session.target.threadId,
148 ),
149 ).toEqual([threadA]);
150 });
151
152 it("reads the metadata array once for all thread selectors", () => {
153 const source = Array.from({ length: 40 }, (_, index) =>
154 summary(`thread-${index}`, "terminal-1"),
155 );
156 let reads = 0;
157 const metadata = new Proxy(source, {
158 get(target, property, receiver) {
159 if (typeof property === "string" && /^(0|[1-9]\d*)$/.test(property)) reads += 1;
160 return Reflect.get(target, property, receiver);
161 },
162 });
163 for (const item of source) {
164 expect(
165 selectKnownTerminalSessions(metadata, environmentA, ThreadId.make(item.threadId)),
166 ).toHaveLength(1);
167 }
168 expect(selectKnownTerminalSessions(metadata, environmentA, null)).toHaveLength(source.length);
169 expect(reads).toBe(source.length);
170 });
171});