1import * as NodeModule from "node:module";
2import { act, createElement, type ReactNode } from "react";
3import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
4
5import { useTerminalLifecycle } from "./useTerminalLifecycle";
6
7const { createRoot } = NodeModule.createRequire(import.meta.url)("react-dom/client") as {
8 createRoot(container: Element): {
9 render(children: ReactNode): void;
10 unmount(): void;
11 };
12};
13
14type Input = Parameters<typeof useTerminalLifecycle>[0];
15let root: ReturnType<typeof createRoot>;
16const reopen = vi.fn<Input["reopen"]>();
17const onRunning = vi.fn<Input["onRunning"]>();
18const onExit = vi.fn<Input["onExit"]>();
19
20function LifecycleProbe(input: Input) {
21 useTerminalLifecycle(input);
22 return null;
23}
24
25function input(changes: Partial<Input> = {}): Input {
26 return {
27 terminalKey: "environment:thread:term-1",
28 canOperate: true,
29 observing: false,
30 attached: true,
31 terminal: { status: "running", version: 1 },
32 reopen,
33 onRunning,
34 onExit,
35 ...changes,
36 };
37}
38
39function render(value: Input) {
40 return act(() => root.render(createElement(LifecycleProbe, value)));
41}
42
43beforeEach(() => {
44 reopen.mockReset().mockResolvedValue(true);
45 onRunning.mockClear();
46 onExit.mockClear();
47 const document = {
48 nodeType: 9,
49 addEventListener() {},
50 removeEventListener() {},
51 };
52 const container = {
53 nodeType: 1,
54 tagName: "DIV",
55 namespaceURI: "http://www.w3.org/1999/xhtml",
56 ownerDocument: document,
57 addEventListener() {},
58 removeEventListener() {},
59 };
60 vi.stubGlobal("document", document);
61 vi.stubGlobal("window", { document, HTMLIFrameElement: EventTarget });
62 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
63 root = createRoot(container as unknown as HTMLElement);
64});
65
66afterEach(async () => {
67 await act(() => root.unmount());
68 vi.unstubAllGlobals();
69});
70
71it.each(["running", "closed", "exited"] as const)(
72 "waits for the replacement attachment snapshot before handling %s",
73 async (status) => {
74 await render(input());
75 await render(input({ terminal: { status: "closed", version: 0 } }));
76 expect(onExit).not.toHaveBeenCalled();
77 expect(reopen).not.toHaveBeenCalled();
78
79 await render(input({ terminal: { status, version: 1 } }));
80 expect(onExit).toHaveBeenCalledTimes(status === "running" ? 0 : 1);
81 expect(reopen).not.toHaveBeenCalled();
82 },
83);
84
85it("retains an exited observer's history after gaining operate", async () => {
86 await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } }));
87 await render(input({ terminal: { status: "closed", version: 0 } }));
88 await render(input({ terminal: { status: "exited", version: 1 } }));
89 expect(reopen).not.toHaveBeenCalled();
90 expect(onExit).not.toHaveBeenCalled();
91});
92
93it.each(["closed", "exited"] as const)(
94 "keeps a pending observer passive when its first writable snapshot is %s",
95 async (status) => {
96 await render(
97 input({
98 canOperate: false,
99 observing: true,
100 terminal: { status, version: 0 },
101 }),
102 );
103 await render(input({ terminal: { status: "closed", version: 0 } }));
104 await render(input({ terminal: { status, version: 1 } }));
105 expect(reopen).not.toHaveBeenCalled();
106 expect(onExit).not.toHaveBeenCalled();
107 expect(onRunning).not.toHaveBeenCalled();
108 },
109);
110
111it("reopens an explicit operator visit after permissions finish loading", async () => {
112 await render(
113 input({
114 canOperate: false,
115 observing: false,
116 terminal: { status: "closed", version: 0 },
117 }),
118 );
119 await render(input({ terminal: { status: "closed", version: 0 } }));
120 await render(input({ terminal: { status: "exited", version: 1 } }));
121 expect(reopen).toHaveBeenCalledOnce();
122 expect(onExit).not.toHaveBeenCalled();
123});
124
125it("does not respawn a terminal that exited after operate was revoked", async () => {
126 await render(input());
127 await render(input({ canOperate: false }));
128 await render(input({ canOperate: false, terminal: { status: "exited", version: 2 } }));
129 await render(input({ terminal: { status: "exited", version: 1 } }));
130 expect(reopen).not.toHaveBeenCalled();
131 expect(onExit).not.toHaveBeenCalled();
132});
133
134it("remembers revocation before the observer has received a snapshot", async () => {
135 await render(input());
136 await render(input({ canOperate: false, terminal: { status: "closed", version: 0 } }));
137 await render(input({ terminal: { status: "exited", version: 1 } }));
138 expect(reopen).not.toHaveBeenCalled();
139 expect(onExit).not.toHaveBeenCalled();
140});
141
142it("cleans up an actual exit after an observed running terminal becomes writable", async () => {
143 await render(input({ canOperate: false }));
144 await render(input());
145 await render(input({ terminal: { status: "exited", version: 2 } }));
146 await render(input({ terminal: { status: "closed", version: 3 } }));
147 expect(onExit).toHaveBeenCalledOnce();
148 expect(reopen).not.toHaveBeenCalled();
149});
150
151it("reopens an ended terminal on an explicit new route visit", async () => {
152 await render(input({ canOperate: false, terminal: { status: "exited", version: 1 } }));
153 await render(input({ terminalKey: "environment:thread:term-2" }));
154 await render(input({ terminal: { status: "exited", version: 1 } }));
155 expect(reopen).toHaveBeenCalledOnce();
156
157 await render(input({ terminal: { status: "exited", version: 2 } }));
158 expect(reopen).toHaveBeenCalledOnce();
159 await render(input({ terminal: { status: "running", version: 3 } }));
160 await render(input({ terminal: { status: "exited", version: 4 } }));
161 expect(onExit).toHaveBeenCalledOnce();
162});
163
164it("keeps a detached terminal from being mistaken for a live exit", async () => {
165 await render(input());
166 await render(input({ attached: false, terminal: { status: "closed", version: 0 } }));
167 await render(input({ terminal: { status: "exited", version: 1 } }));
168 expect(onExit).not.toHaveBeenCalled();
169 expect(reopen).toHaveBeenCalledOnce();
170});
171
172it("allows a later snapshot to retry an unsuccessful explicit reopen", async () => {
173 reopen.mockResolvedValueOnce(false);
174 await render(input({ terminal: { status: "exited", version: 1 } }));
175 await render(input({ terminal: { status: "exited", version: 2 } }));
176 expect(reopen).toHaveBeenCalledTimes(2);
177});