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