Open History to see the last commit
1import { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
3import { expect, it } from "vite-plus/test";
4
5import {
6 createTerminalSession,
7 INITIAL_TERMINAL_LIFECYCLE,
8 transitionTerminalLifecycle,
9 type TerminalLifecycleEvent,
10 type TerminalSessionMode,
11} from "./terminal.ts";
12import { EMPTY_TERMINAL_BUFFER_STATE, type TerminalBufferState } from "./terminalSession.ts";
13
14function run(events: ReadonlyArray<TerminalLifecycleEvent>) {
15 let state = INITIAL_TERMINAL_LIFECYCLE;
16 const commands: Array<string> = [];
17 for (const event of events) {
18 const step = transitionTerminalLifecycle(state, event);
19 state = step.state;
20 if (step.command !== null) commands.push(step.command);
21 }
22 return commands;
23}
24
25const status = (value: TerminalBufferState["status"], version = 1): TerminalLifecycleEvent => ({
26 type: "status",
27 status: value,
28 version,
29});
30
31it("does not respawn a terminal that exited after operate was revoked", () => {
32 expect(
33 run([status("running"), { type: "detached", canOperate: false }, status("exited")]),
34 ).toEqual([]);
35});
36
37it("reopens an ended terminal on an explicit visit, once until it runs again", () => {
38 expect(run([status("exited"), status("exited", 2)])).toEqual(["reopen"]);
39 expect(run([status("exited"), status("running", 3), status("exited", 4)])).toEqual([
40 "reopen",
41 "exit",
42 ]);
43 expect(run([status("exited"), { type: "reopen-failed" }, status("exited", 2)])).toEqual([
44 "reopen",
45 "reopen",
46 ]);
47});
48
49it("keeps a detached terminal from being mistaken for a live exit", () => {
50 expect(
51 run([status("running"), { type: "detached", canOperate: true }, status("exited")]),
52 ).toEqual(["reopen"]);
53});
54
55it("leaves the history a viewer was reading when operate is granted", () => {
56 expect(run([{ type: "observed" }, status("exited")])).toEqual([]);
57});
58
59const environmentId = EnvironmentId.make("environment");
60const threadId = ThreadId.make("thread");
61
62function setup(options: { readonly initialInput?: string } = {}) {
63 // Tests flush the registry's scheduled work to control when driver finalizers run.
64 const scheduled: Array<() => void> = [];
65 const registry = AtomRegistry.make({
66 scheduleTask: (task) => {
67 scheduled.push(task);
68 return () => {};
69 },
70 });
71 const flush = () => {
72 for (const task of scheduled.splice(0)) task();
73 };
74 const attachment = Atom.make<AsyncResult.AsyncResult<TerminalBufferState, unknown>>(
75 AsyncResult.initial(true),
76 );
77 const observation = Atom.make<AsyncResult.AsyncResult<TerminalBufferState, unknown>>(
78 AsyncResult.initial(true),
79 );
80 const access = { canOperate: true };
81 const calls: Array<{ readonly command: string; readonly input: unknown }> = [];
82 const command = (name: string) => ({
83 label: name,
84 run: async (_registry: AtomRegistry.AtomRegistry, value: { readonly input: unknown }) => {
85 calls.push({ command: name, input: value.input });
86 return AsyncResult.success(undefined);
87 },
88 });
89 const exits: Array<true> = [];
90 const live: Array<boolean> = [];
91 const session = createTerminalSession(
92 {
93 attach: () => attachment,
94 observe: () => observation,
95 open: command("open"),
96 resize: command("resize"),
97 write: command("write"),
98 close: command("close"),
99 },
100 {
101 target: { environmentId, threadId, terminalId: "term-1" },
102 gridSize: { cols: 120, rows: 40 },
103 initialInput: options.initialInput,
104 canOperate: () => access.canOperate,
105 onExit: () => exits.push(true),
106 onLiveChange: (value) => live.push(value),
107 },
108 );
109 const attached: TerminalSessionMode = {
110 kind: "attached",
111 input: { threadId, terminalId: "term-1", cwd: "/repo", cols: 80, rows: 24 },
112 };
113 const snapshot = (state: Partial<TerminalBufferState> & { readonly generation?: number }) => {
114 const { generation = 1, ...rest } = state;
115 return AsyncResult.success({
116 ...EMPTY_TERMINAL_BUFFER_STATE,
117 output: { ...EMPTY_TERMINAL_BUFFER_STATE.output, generation },
118 ...rest,
119 });
120 };
121 const emit = (state: Partial<TerminalBufferState> & { readonly generation?: number }) =>
122 registry.set(attachment, snapshot(state));
123 // A new attach subscription starts from an empty seed.
124 const detach = () => registry.set(attachment, AsyncResult.initial(true));
125 const observe = (state: Partial<TerminalBufferState>) =>
126 registry.set(observation, snapshot(state));
127 const mount = (mode: TerminalSessionMode) => registry.mount(session.driver(mode));
128 const of = (name: string) => calls.filter((call) => call.command === name).map((c) => c.input);
129 return { session, attached, access, flush, detach, emit, observe, mount, calls, of, exits, live };
130}
131
132it("replays the measured grid on every new attachment generation", () => {
133 const { session, attached, emit, mount, of } = setup();
134 mount(attached);
135 emit({ status: "running", version: 1 });
136 expect(of("resize")).toEqual([{ threadId, terminalId: "term-1", cols: 120, rows: 40 }]);
137
138 session.reportGridSize({ cols: 100, rows: 30 });
139 expect(of("resize")).toHaveLength(2);
140 session.reportGridSize({ cols: 100, rows: 30 });
141 expect(of("resize")).toHaveLength(2);
142
143 // A reconnect seeds an empty state first, then the new generation's snapshot.
144 emit({ status: "running", version: 0, generation: 2 });
145 expect(of("resize")).toHaveLength(2);
146 emit({ status: "running", version: 1, generation: 2 });
147 expect(of("resize")).toHaveLength(3);
148 emit({ status: "running", version: 2, generation: 3 });
149 expect(of("resize")).toEqual([
150 { threadId, terminalId: "term-1", cols: 120, rows: 40 },
151 { threadId, terminalId: "term-1", cols: 100, rows: 30 },
152 { threadId, terminalId: "term-1", cols: 100, rows: 30 },
153 { threadId, terminalId: "term-1", cols: 100, rows: 30 },
154 ]);
155});
156
157it("writes the launch's initial input once the first snapshot lands", () => {
158 const { attached, emit, mount, calls, of } = setup({ initialInput: "pnpm dev\r" });
159 mount(attached);
160 expect(of("write")).toEqual([]);
161 emit({ status: "running", version: 1 });
162 emit({ status: "running", version: 2 });
163 expect(of("write")).toEqual([{ threadId, terminalId: "term-1", data: "pnpm dev\r" }]);
164 // The shell gets the measured grid before the launched command runs.
165 expect(calls.map((call) => call.command)).toEqual(["resize", "write"]);
166});
167
168it("closes and reports a shell that exits while attached", () => {
169 const { attached, emit, mount, of, exits, live } = setup();
170 mount(attached);
171 emit({ status: "running", version: 1, lifecycleVersion: 0 });
172 emit({ status: "exited", version: 2 });
173 expect(of("close")).toEqual([{ threadId, terminalId: "term-1" }]);
174 expect(of("open")).toEqual([]);
175 expect(exits).toHaveLength(1);
176 expect(live).toEqual([true, false]);
177});
178
179it("does not reopen an ended shell after a viewer is granted operate", () => {
180 const { attached, flush, access, emit, observe, mount, of } = setup();
181 access.canOperate = false;
182 const unmountViewer = mount({ kind: "observing" });
183 observe({ status: "exited", version: 1 });
184 access.canOperate = true;
185 unmountViewer();
186 flush();
187 mount(attached);
188 emit({ status: "exited", version: 1 });
189 expect(of("open")).toEqual([]);
190});
191
192it("does not reopen on grant when the viewer had no snapshot yet", () => {
193 const { attached, flush, access, emit, mount, of } = setup();
194 access.canOperate = false;
195 const unmountViewer = mount({ kind: "observing" });
196 access.canOperate = true;
197 unmountViewer();
198 flush();
199 mount(attached);
200 emit({ status: "exited", version: 1 });
201 expect(of("open")).toEqual([]);
202});
203
204it("leaves a shell that exited after operate was revoked mid-run", () => {
205 const { attached, flush, access, detach, emit, mount, of, exits } = setup();
206 const unmountAttached = mount(attached);
207 emit({ status: "running", version: 1 });
208 access.canOperate = false;
209 unmountAttached();
210 flush();
211 detach();
212 access.canOperate = true;
213 mount(attached);
214 emit({ status: "exited", version: 2 });
215 expect(of("open")).toEqual([]);
216 expect(of("close")).toEqual([]);
217 expect(exits).toEqual([]);
218});
219
220it("reopens instead of closing a shell that ended while detached", () => {
221 const { attached, flush, detach, emit, mount, of, exits } = setup();
222 const unmountAttached = mount(attached);
223 emit({ status: "running", version: 1 });
224 unmountAttached();
225 flush();
226 detach();
227 mount(attached);
228 emit({ status: "exited", version: 2 });
229 expect(of("close")).toEqual([]);
230 expect(exits).toEqual([]);
231 expect(of("open")).toHaveLength(1);
232});
233
234it("keeps one attached driver, so a remount before release keeps its state", () => {
235 const { session, attached, flush, emit, mount, of, exits } = setup();
236 const driver = session.driver(attached);
237 expect(session.driver({ ...attached })).toBe(driver);
238 const unmountFirst = mount(attached);
239 emit({ status: "running", version: 1 });
240 // A brief disconnect unmounts and remounts the driver before its release runs.
241 unmountFirst();
242 mount(attached);
243 flush();
244 emit({ status: "exited", version: 2 });
245 expect(of("close")).toHaveLength(1);
246 expect(exits).toHaveLength(1);
247});