| 1 | import { EnvironmentId, ThreadId, type TerminalSummary } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | const { 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 | |
| 13 | vi.mock("~/localApi", () => ({ readLocalApi: readLocalApiMock })); |
| 14 | vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: registry })); |
| 15 | vi.mock("~/state/terminal", () => ({ |
| 16 | terminalEnvironment: { metadataSnapshot: metadataSnapshotMock }, |
| 17 | })); |
| 18 | vi.mock("@t3tools/client-runtime/state/runtime", () => ({ |
| 19 | executeAtomQuery: executeAtomQueryMock, |
| 20 | })); |
| 21 | |
| 22 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "./terminalCloseConfirm"; |
| 23 | |
| 24 | const threadRef = { |
| 25 | environmentId: EnvironmentId.make("secondary"), |
| 26 | threadId: ThreadId.make("thread"), |
| 27 | }; |
| 28 | const 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 | |
| 42 | function metadata(terminals: ReadonlyArray<TerminalSummary>) { |
| 43 | return { _tag: "Success", value: terminals }; |
| 44 | } |
| 45 | |
| 46 | describe("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 | }); |