1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 EnvironmentId,
5 retainedTerminalHistory,
6 TerminalSessionSnapshot,
7 ThreadId,
8} from "@t3tools/contracts";
9
10import {
11 applyTerminalAttachStreamEvent,
12 applyTerminalMetadataStreamEvent,
13 combineTerminalSessionState,
14 EMPTY_TERMINAL_BUFFER_STATE,
15 INITIAL_TERMINAL_OUTPUT_CURSOR,
16 nextTerminalAttachSeedState,
17 readTerminalOutputUpdate,
18 terminalOutputText,
19} from "./terminalSession.ts";
20
21const TARGET = {
22 environmentId: EnvironmentId.make("env-local"),
23 threadId: ThreadId.make("thread-1"),
24 terminalId: "term-1",
25} as const;
26
27const BASE_SNAPSHOT: TerminalSessionSnapshot = {
28 threadId: TARGET.threadId,
29 terminalId: TARGET.terminalId,
30 cwd: "/repo",
31 worktreePath: null,
32 status: "running",
33 pid: 123,
34 history: "hello",
35 exitCode: null,
36 exitSignal: null,
37 label: "Terminal 1",
38 updatedAt: "2026-04-01T00:00:00.000Z",
39};
40
41describe("terminal session reducers", () => {
42 it("prefers live attach status over stale metadata after the attach stream starts", () => {
43 const summary = applyTerminalMetadataStreamEvent([], {
44 type: "snapshot",
45 terminals: [
46 {
47 threadId: BASE_SNAPSHOT.threadId,
48 terminalId: BASE_SNAPSHOT.terminalId,
49 cwd: BASE_SNAPSHOT.cwd,
50 worktreePath: BASE_SNAPSHOT.worktreePath,
51 status: "running",
52 pid: BASE_SNAPSHOT.pid,
53 exitCode: BASE_SNAPSHOT.exitCode,
54 exitSignal: BASE_SNAPSHOT.exitSignal,
55 updatedAt: BASE_SNAPSHOT.updatedAt,
56 hasRunningSubprocess: false,
57 label: BASE_SNAPSHOT.label,
58 },
59 ],
60 })[0]!;
61 const attached = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
62 type: "error",
63 threadId: TARGET.threadId,
64 terminalId: TARGET.terminalId,
65 message: "Terminal disconnected.",
66 });
67
68 expect(combineTerminalSessionState(summary, attached)).toMatchObject({
69 status: "error",
70 error: "Terminal disconnected.",
71 version: 1,
72 });
73 });
74
75 it("follows the shared PTY grid from snapshots and activity without disturbing output", () => {
76 const attached = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
77 type: "snapshot",
78 snapshot: { ...BASE_SNAPSHOT, cols: 130, rows: 15 },
79 });
80 const activity = {
81 type: "activity",
82 threadId: TARGET.threadId,
83 terminalId: TARGET.terminalId,
84 hasRunningSubprocess: false,
85 label: "Terminal 1",
86 } as const;
87 const narrowed = applyTerminalAttachStreamEvent(attached, { ...activity, cols: 54, rows: 15 });
88 // Activity from a server without geometry, or a repeat, must not reset the renderer.
89 const unchanged = applyTerminalAttachStreamEvent(
90 applyTerminalAttachStreamEvent(narrowed, activity),
91 { ...activity, cols: 54, rows: 15 },
92 );
93
94 expect(attached.grid).toEqual({ cols: 130, rows: 15 });
95 expect(narrowed).toEqual({ ...attached, grid: { cols: 54, rows: 15 } });
96 expect(unchanged).toBe(narrowed);
97 });
98
99 it("preserves a BOM code point at a new output chunk boundary", () => {
100 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
101 type: "snapshot",
102 snapshot: { ...BASE_SNAPSHOT, history: "" },
103 });
104 const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
105 const data = `${"x".repeat(16_384)}\uFEFF${"y".repeat(20)}`;
106 const state = applyTerminalAttachStreamEvent(initial, {
107 type: "output",
108 threadId: TARGET.threadId,
109 terminalId: TARGET.terminalId,
110 data,
111 });
112
113 expect(terminalOutputText(state.output)).toBe(data);
114 expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({ type: "append", data });
115 expect(state.output.retainedBytes).toBe(new TextEncoder().encode(data).byteLength);
116 });
117
118 it("preserves a leading BOM in the retained snapshot tail", () => {
119 const state = applyTerminalAttachStreamEvent(
120 EMPTY_TERMINAL_BUFFER_STATE,
121 { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "discard\uFEFFtail" } },
122 7,
123 );
124
125 expect(terminalOutputText(state.output)).toBe("\uFEFFtail");
126 expect(state.output.retainedBytes).toBe(7);
127 const serverTail = retainedTerminalHistory("discardtail", 7);
128 expect(
129 applyTerminalAttachStreamEvent(
130 EMPTY_TERMINAL_BUFFER_STATE,
131 { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: serverTail } },
132 7,
133 ),
134 ).toEqual(state);
135 });
136
137 it("trims whole Unicode code points from a partially retained chunk", () => {
138 let state = applyTerminalAttachStreamEvent(
139 EMPTY_TERMINAL_BUFFER_STATE,
140 { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "é界🙂end" } },
141 12,
142 );
143 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
144 for (const [data, expected, retainedBytes] of [
145 ["x", "界🙂endx", 11],
146 ["yz", "🙂endxyz", 10],
147 ["abc", "endxyzabc", 9],
148 ] as const) {
149 state = applyTerminalAttachStreamEvent(
150 state,
151 { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
152 12,
153 );
154 const update = readTerminalOutputUpdate(state.output, cursor);
155 expect(update).toMatchObject({ type: "append", data });
156 expect(terminalOutputText(state.output)).toBe(expected);
157 expect(state.output.retainedBytes).toBe(retainedBytes);
158 cursor = update.cursor;
159 }
160 });
161
162 it("delivers all output when several events arrive before a renderer reads", () => {
163 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
164 type: "snapshot",
165 snapshot: BASE_SNAPSHOT,
166 });
167 const cursor = readTerminalOutputUpdate(initial.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
168 let state = initial;
169 for (const data of [" one", " two", " three"]) {
170 state = applyTerminalAttachStreamEvent(state, {
171 type: "output",
172 threadId: TARGET.threadId,
173 terminalId: TARGET.terminalId,
174 data,
175 });
176 }
177 const update = readTerminalOutputUpdate(state.output, cursor);
178 expect(update).toMatchObject({ type: "append", data: " one two three" });
179 expect(readTerminalOutputUpdate(state.output, update.cursor).type).toBe("none");
180 });
181
182 it("bounds historical chunk inspection when reading new tail output", () => {
183 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
184 type: "snapshot",
185 snapshot: { ...BASE_SNAPSHOT, history: "" },
186 });
187 for (let index = 0; index < 512; index += 1) {
188 state = applyTerminalAttachStreamEvent(state, {
189 type: "output",
190 threadId: TARGET.threadId,
191 terminalId: TARGET.terminalId,
192 data: "history\r\n",
193 });
194 }
195 const cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
196 const next = applyTerminalAttachStreamEvent(state, {
197 type: "output",
198 threadId: TARGET.threadId,
199 terminalId: TARGET.terminalId,
200 data: "latest\r\n",
201 });
202 let historicalChunkReads = 0;
203 const chunks = next.output.chunks.map((chunk, index) =>
204 index < state.output.chunks.length
205 ? {
206 ...chunk,
207 get startOffset() {
208 historicalChunkReads += 1;
209 return chunk.startOffset;
210 },
211 }
212 : chunk,
213 );
214
215 expect(readTerminalOutputUpdate({ ...next.output, chunks }, cursor)).toMatchObject({
216 type: "append",
217 data: "latest\r\n",
218 });
219 expect(historicalChunkReads).toBeLessThanOrEqual(2);
220 });
221
222 it("preserves the byte-limited tail and resets a cursor before a partially trimmed chunk", () => {
223 const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
224 type: "snapshot",
225 snapshot: { ...BASE_SNAPSHOT, history: "" },
226 });
227 const staleCursor = readTerminalOutputUpdate(
228 initial.output,
229 INITIAL_TERMINAL_OUTPUT_CURSOR,
230 ).cursor;
231 const first = applyTerminalAttachStreamEvent(initial, {
232 type: "output",
233 threadId: TARGET.threadId,
234 terminalId: TARGET.terminalId,
235 data: "hello",
236 });
237 const caughtUpCursor = readTerminalOutputUpdate(first.output, staleCursor).cursor;
238 const state = applyTerminalAttachStreamEvent(
239 first,
240 { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data: " world" },
241 8,
242 );
243
244 expect(readTerminalOutputUpdate(state.output, caughtUpCursor)).toMatchObject({
245 type: "append",
246 data: " world",
247 });
248 expect(readTerminalOutputUpdate(state.output, staleCursor)).toMatchObject({
249 type: "reset",
250 data: "lo world",
251 });
252 expect(state.output.retainedBytes).toBe(8);
253 });
254
255 it("resets for repeated snapshots, clear, and restart even when output text repeats", () => {
256 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
257 type: "snapshot",
258 snapshot: BASE_SNAPSHOT,
259 });
260 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
261 const attachedGeneration = state.output.generation;
262 // A socket reconnect resubscribes the same stream, so the server's new snapshot gets no seed.
263 state = applyTerminalAttachStreamEvent(state, { type: "snapshot", snapshot: BASE_SNAPSHOT });
264 const repeated = readTerminalOutputUpdate(state.output, cursor);
265 expect(repeated).toMatchObject({ type: "reset", data: "hello" });
266 expect(state.version).toBe(2);
267 // Viewers replay their measured grid once per generation; the new attachment holds the old size.
268 expect(state.output.generation).not.toBe(attachedGeneration);
269 cursor = repeated.cursor;
270
271 state = applyTerminalAttachStreamEvent(state, {
272 type: "cleared",
273 threadId: TARGET.threadId,
274 terminalId: TARGET.terminalId,
275 });
276 const cleared = readTerminalOutputUpdate(state.output, cursor);
277 expect(cleared).toMatchObject({ type: "reset", data: "" });
278 state = applyTerminalAttachStreamEvent(state, {
279 type: "restarted",
280 threadId: TARGET.threadId,
281 terminalId: TARGET.terminalId,
282 snapshot: BASE_SNAPSHOT,
283 });
284 expect(readTerminalOutputUpdate(state.output, cleared.cursor)).toMatchObject({
285 type: "reset",
286 data: "hello",
287 });
288 expect(state.lifecycleVersion).toBe(2);
289 });
290
291 it("does not reuse a renderer cursor when a fresh attach has matching counters", () => {
292 const first = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
293 type: "snapshot",
294 snapshot: BASE_SNAPSHOT,
295 });
296 const cursor = readTerminalOutputUpdate(first.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
297 const next = applyTerminalAttachStreamEvent(nextTerminalAttachSeedState(), {
298 type: "snapshot",
299 snapshot: { ...BASE_SNAPSHOT, history: "other" },
300 });
301 expect(next.output.resetVersion).toBe(first.output.resetVersion);
302 expect(next.output.nextOffset).toBe(first.output.nextOffset);
303 expect(readTerminalOutputUpdate(next.output, cursor)).toMatchObject({
304 type: "reset",
305 data: "other",
306 });
307 expect(next.lifecycleVersion).toBe(0);
308 });
309
310 it("appends every unread character across a compaction boundary", () => {
311 let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
312 type: "snapshot",
313 snapshot: { ...BASE_SNAPSHOT, history: "" },
314 });
315 let cursor = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR).cursor;
316 const writes = Array.from({ length: 1200 }, (_, index) => ["x", "é", "界", "🙂"][index % 4]!);
317 for (const [index, data] of writes.entries()) {
318 state = applyTerminalAttachStreamEvent(state, {
319 type: "output",
320 threadId: TARGET.threadId,
321 terminalId: TARGET.terminalId,
322 data,
323 });
324 if (index === 99) cursor = readTerminalOutputUpdate(state.output, cursor).cursor;
325 }
326 expect(readTerminalOutputUpdate(state.output, cursor)).toMatchObject({
327 type: "append",
328 data: writes.slice(100).join(""),
329 });
330 });
331});