1import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const { confirmMock, readLocalApiMock, metadataSnapshotMock, executeAtomQueryMock, registry } =
5 vi.hoisted(() => ({
6 confirmMock: vi.fn<(message: string, options?: unknown) => Promise<boolean>>(),
7 readLocalApiMock: vi.fn(),
8 metadataSnapshotMock: vi.fn(() => "metadata-snapshot"),
9 executeAtomQueryMock: vi.fn(),
10 registry: {},
11 }));
12
13vi.mock("~/localApi", () => ({ readLocalApi: readLocalApiMock }));
14vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: registry }));
15vi.mock("~/state/terminal", () => ({
16 terminalEnvironment: { metadataSnapshot: metadataSnapshotMock },
17}));
18vi.mock("@t3tools/client-runtime/state/runtime", () => ({
19 executeAtomQuery: executeAtomQueryMock,
20}));
21
22import { confirmTerminalClose, isTerminalCloseConfirmPending } from "./terminalCloseConfirm";
23
24const threadRef = {
25 environmentId: EnvironmentId.make("secondary"),
26 threadId: ThreadId.make("thread"),
27};
28const terminal: TerminalSummary = {
29 threadId: threadRef.threadId,
30 terminalId: "term-1",
31 cwd: "/repo",
32 worktreePath: null,
33 status: "running",
34 pid: 123,
35 exitCode: null,
36 exitSignal: null,
37 hasRunningSubprocess: true,
38 label: "Terminal 1",
39 updatedAt: "2026-10-15T00:00:00.000Z",
40};
41
42function metadata(terminals: ReadonlyArray<TerminalSummary>) {
43 return { _tag: "Success", value: terminals };
44}
45
46describe("terminal close confirmation", () => {
47 beforeEach(() => {
48 vi.clearAllMocks();
49 readLocalApiMock.mockReturnValue({ dialogs: { confirm: confirmMock } });
50 executeAtomQueryMock.mockResolvedValue(metadata([terminal]));
51 confirmMock.mockResolvedValue(true);
52 });
53
54 it("closes an idle shell without a dialog or a pending confirmation after the lookup", async () => {
55 executeAtomQueryMock.mockResolvedValue(
56 metadata([{ ...terminal, hasRunningSubprocess: false }]),
57 );
58
59 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(true);
60
61 expect(confirmMock).not.toHaveBeenCalled();
62 expect(isTerminalCloseConfirmPending()).toBe(false);
63 });
64
65 it("closes an exited terminal without prompting", async () => {
66 executeAtomQueryMock.mockResolvedValue(
67 metadata([{ ...terminal, status: "exited", pid: null, hasRunningSubprocess: false }]),
68 );
69
70 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(true);
71 expect(confirmMock).not.toHaveBeenCalled();
72 });
73
74 it("refreshes the exact environment before each close so a newly started program prompts", async () => {
75 executeAtomQueryMock
76 .mockResolvedValueOnce(metadata([{ ...terminal, hasRunningSubprocess: false }]))
77 .mockResolvedValueOnce(metadata([terminal]));
78
79 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(true);
80 expect(confirmMock).not.toHaveBeenCalled();
81 confirmMock.mockResolvedValueOnce(false);
82 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(false);
83
84 expect(metadataSnapshotMock).toHaveBeenNthCalledWith(1, {
85 environmentId: threadRef.environmentId,
86 input: null,
87 });
88 expect(executeAtomQueryMock).toHaveBeenNthCalledWith(2, registry, "metadata-snapshot", {
89 refresh: true,
90 reportFailure: false,
91 signal: expect.any(AbortSignal),
92 });
93 expect(confirmMock).toHaveBeenCalledExactlyOnceWith(
94 'Close terminal "Terminal 1"?\nThis stops the running process and clears its history.',
95 { variant: "destructive" },
96 );
97 });
98
99 it("does not let another thread's idle terminal suppress the warning", async () => {
100 executeAtomQueryMock.mockResolvedValue(
101 metadata([{ ...terminal, threadId: "other-thread", hasRunningSubprocess: false }]),
102 );
103
104 await confirmTerminalClose(threadRef, ["term-1"]);
105 expect(confirmMock).toHaveBeenCalledOnce();
106 });
107
108 it.each([metadata([]), { _tag: "Failure" }])(
109 "warns when terminal activity is unknown: %j",
110 async (result) => {
111 executeAtomQueryMock.mockResolvedValue(result);
112 confirmMock.mockResolvedValue(false);
113
114 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(false);
115 expect(confirmMock).toHaveBeenCalledOnce();
116 },
117 );
118
119 it("closes a split surface directly only when every terminal is known idle", async () => {
120 executeAtomQueryMock.mockResolvedValue(
121 metadata([
122 { ...terminal, hasRunningSubprocess: false },
123 { ...terminal, terminalId: "term-2", hasRunningSubprocess: false },
124 ]),
125 );
126
127 await expect(confirmTerminalClose(threadRef, ["term-1", "term-2"])).resolves.toBe(true);
128 expect(confirmMock).not.toHaveBeenCalled();
129
130 await confirmTerminalClose(threadRef, ["term-1", "term-3"]);
131 expect(confirmMock).toHaveBeenCalledOnce();
132 });
133
134 it("names every terminal when a split surface includes a running program", async () => {
135 executeAtomQueryMock.mockResolvedValue(
136 metadata([
137 { ...terminal, hasRunningSubprocess: false },
138 { ...terminal, terminalId: "term-2", label: "Development server" },
139 ]),
140 );
141
142 await expect(confirmTerminalClose(threadRef, ["term-1", "term-2"])).resolves.toBe(true);
143 expect(confirmMock).toHaveBeenCalledWith(
144 [
145 "Close 2 terminals?",
146 'This stops their running processes and clears their histories: "Terminal 1", "Development server".',
147 ].join("\n"),
148 { variant: "destructive" },
149 );
150 });
151
152 it("tracks pending state through the activity lookup and until the confirmation settles", async () => {
153 let completeLookup: (value: ReturnType<typeof metadata>) => void = () => undefined;
154 executeAtomQueryMock.mockImplementationOnce(
155 () => new Promise<ReturnType<typeof metadata>>((resolve) => (completeLookup = resolve)),
156 );
157 let settle: (value: boolean) => void = () => undefined;
158 const dialogOpened = new Promise<void>((resolve) => {
159 confirmMock.mockImplementationOnce(() => {
160 resolve();
161 return new Promise<boolean>((resolveConfirmation) => (settle = resolveConfirmation));
162 });
163 });
164
165 expect(isTerminalCloseConfirmPending()).toBe(false);
166 const confirmation = confirmTerminalClose(threadRef, ["term-1"]);
167 expect(isTerminalCloseConfirmPending()).toBe(true);
168
169 completeLookup(metadata([terminal]));
170 await dialogOpened;
171 expect(isTerminalCloseConfirmPending()).toBe(true);
172 settle(true);
173 await expect(confirmation).resolves.toBe(true);
174 expect(isTerminalCloseConfirmPending()).toBe(false);
175 });
176
177 it("clears pending state and resolves false when the dialog rejects", async () => {
178 confirmMock.mockRejectedValueOnce(new Error("dialog failed"));
179
180 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(false);
181 expect(isTerminalCloseConfirmPending()).toBe(false);
182 });
183
184 it("closes without prompting when no local API is available", async () => {
185 readLocalApiMock.mockReturnValue(undefined);
186
187 await expect(confirmTerminalClose(threadRef, ["term-1"])).resolves.toBe(true);
188 expect(confirmMock).not.toHaveBeenCalled();
189 expect(executeAtomQueryMock).not.toHaveBeenCalled();
190 });
191});