1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 DEFAULT_TERMINAL_ID,
6 TerminalAttachInput,
7 TerminalClearInput,
8 TerminalCloseInput,
9 TerminalEvent,
10 TerminalError,
11 TerminalOpenInput,
12 TerminalProviderEnvironmentError,
13 TerminalResizeInput,
14 TerminalSessionSnapshot,
15 TerminalThreadInput,
16 TerminalWriteInput,
17} from "./terminal.ts";
18import { ProviderInstanceId } from "./providerInstance.ts";
19
20const encodeTerminalError = Schema.encodeUnknownSync(TerminalError);
21const decodeTerminalError = Schema.decodeUnknownSync(TerminalError);
22
23function decodeSync<S extends Schema.Top>(schema: S, input: unknown): Schema.Schema.Type<S> {
24 return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type<S>;
25}
26
27function decodes<S extends Schema.Top>(schema: S, input: unknown): boolean {
28 try {
29 Schema.decodeUnknownSync(schema as never)(input);
30 return true;
31 } catch {
32 return false;
33 }
34}
35
36describe("TerminalProviderEnvironmentError", () => {
37 it("round-trips its required cause without exposing it in the message", () => {
38 const cause = { operation: "read-secret", detail: "secret backend unavailable" };
39 const error = new TerminalProviderEnvironmentError({
40 providerInstanceId: ProviderInstanceId.make("codex_work"),
41 cause,
42 });
43 const encoded = encodeTerminalError(error);
44 const decoded = decodeTerminalError(encoded);
45
46 expect(decoded).toMatchObject({
47 _tag: "TerminalProviderEnvironmentError",
48 providerInstanceId: "codex_work",
49 cause,
50 });
51 expect(decoded.message).toBe(
52 "Could not prepare the terminal environment for provider instance: codex_work",
53 );
54 expect(decoded.message).not.toContain("secret backend unavailable");
55 });
56});
57
58describe("TerminalOpenInput", () => {
59 it("accepts valid open input", () => {
60 expect(
61 decodes(TerminalOpenInput, {
62 threadId: "thread-1",
63 terminalId: DEFAULT_TERMINAL_ID,
64 cwd: "/tmp/project",
65 cols: 120,
66 rows: 40,
67 }),
68 ).toBe(true);
69 });
70
71 it("accepts ultrawide terminal dimensions from xterm fit", () => {
72 expect(
73 decodes(TerminalOpenInput, {
74 threadId: "thread-1",
75 terminalId: DEFAULT_TERMINAL_ID,
76 cwd: "/tmp/project",
77 cols: 423,
78 rows: 40,
79 }),
80 ).toBe(true);
81 });
82
83 it("rejects invalid bounds", () => {
84 expect(
85 decodes(TerminalOpenInput, {
86 threadId: "thread-1",
87 terminalId: DEFAULT_TERMINAL_ID,
88 cwd: "/tmp/project",
89 cols: 10,
90 rows: 0,
91 }),
92 ).toBe(false);
93 });
94
95 it("requires terminalId — the client must always pick an id", () => {
96 expect(
97 decodes(TerminalOpenInput, {
98 threadId: "thread-1",
99 cwd: "/tmp/project",
100 cols: 100,
101 rows: 24,
102 }),
103 ).toBe(false);
104 });
105
106 it("accepts optional env overrides", () => {
107 const parsed = decodeSync(TerminalOpenInput, {
108 threadId: "thread-1",
109 terminalId: DEFAULT_TERMINAL_ID,
110 cwd: "/tmp/project",
111 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
112 cols: 100,
113 rows: 24,
114 env: {
115 T3CODE_PROJECT_ROOT: "/tmp/project",
116 CUSTOM_FLAG: "1",
117 },
118 providerInstanceId: "codex_work",
119 });
120 expect(parsed.env).toMatchObject({
121 T3CODE_PROJECT_ROOT: "/tmp/project",
122 CUSTOM_FLAG: "1",
123 });
124 expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a");
125 expect(parsed.providerInstanceId).toBe("codex_work");
126 });
127
128 it("rejects invalid env keys", () => {
129 expect(
130 decodes(TerminalOpenInput, {
131 threadId: "thread-1",
132 cwd: "/tmp/project",
133 cols: 100,
134 rows: 24,
135 env: {
136 "bad-key": "1",
137 },
138 }),
139 ).toBe(false);
140 });
141
142 it("rejects invalid provider instance ids", () => {
143 for (const providerInstanceId of ["", "1invalid", "invalid id"]) {
144 expect(
145 decodes(TerminalOpenInput, {
146 threadId: "thread-1",
147 terminalId: DEFAULT_TERMINAL_ID,
148 cwd: "/tmp/project",
149 providerInstanceId,
150 }),
151 ).toBe(false);
152 }
153 });
154});
155
156describe("TerminalAttachInput", () => {
157 it("accepts explicit inactive-session restart intent", () => {
158 const parsed = decodeSync(TerminalAttachInput, {
159 threadId: "thread-1",
160 terminalId: DEFAULT_TERMINAL_ID,
161 cwd: "/tmp/project",
162 restartIfNotRunning: true,
163 });
164
165 expect(parsed.restartIfNotRunning).toBe(true);
166 });
167});
168
169describe("TerminalWriteInput", () => {
170 it("accepts non-empty data", () => {
171 expect(
172 decodes(TerminalWriteInput, {
173 threadId: "thread-1",
174 terminalId: DEFAULT_TERMINAL_ID,
175 data: "echo hello\n",
176 }),
177 ).toBe(true);
178 });
179
180 it("rejects empty data", () => {
181 expect(
182 decodes(TerminalWriteInput, {
183 threadId: "thread-1",
184 terminalId: DEFAULT_TERMINAL_ID,
185 data: "",
186 }),
187 ).toBe(false);
188 });
189
190 it("rejects missing terminalId", () => {
191 expect(
192 decodes(TerminalWriteInput, {
193 threadId: "thread-1",
194 data: "echo hello\n",
195 }),
196 ).toBe(false);
197 });
198});
199
200describe("TerminalThreadInput", () => {
201 it("trims thread ids", () => {
202 const parsed = decodeSync(TerminalThreadInput, { threadId: " thread-1 " });
203 expect(parsed.threadId).toBe("thread-1");
204 });
205});
206
207describe("TerminalResizeInput", () => {
208 it("accepts valid size", () => {
209 expect(
210 decodes(TerminalResizeInput, {
211 threadId: "thread-1",
212 terminalId: DEFAULT_TERMINAL_ID,
213 cols: 80,
214 rows: 24,
215 }),
216 ).toBe(true);
217 });
218
219 it("rejects missing terminalId", () => {
220 expect(
221 decodes(TerminalResizeInput, {
222 threadId: "thread-1",
223 cols: 80,
224 rows: 24,
225 }),
226 ).toBe(false);
227 });
228});
229
230describe("TerminalClearInput", () => {
231 it("requires terminalId", () => {
232 expect(decodes(TerminalClearInput, { threadId: "thread-1" })).toBe(false);
233 });
234
235 it("accepts an explicit terminalId", () => {
236 const parsed = decodeSync(TerminalClearInput, {
237 threadId: "thread-1",
238 terminalId: DEFAULT_TERMINAL_ID,
239 });
240 expect(parsed.terminalId).toBe(DEFAULT_TERMINAL_ID);
241 });
242});
243
244describe("TerminalCloseInput", () => {
245 it("accepts optional deleteHistory", () => {
246 expect(
247 decodes(TerminalCloseInput, {
248 threadId: "thread-1",
249 deleteHistory: true,
250 }),
251 ).toBe(true);
252 });
253});
254
255describe("TerminalSessionSnapshot", () => {
256 const isoTimestamp = "2026-01-01T00:00:00.000Z";
257
258 it("accepts running snapshots", () => {
259 expect(
260 decodes(TerminalSessionSnapshot, {
261 threadId: "thread-1",
262 terminalId: DEFAULT_TERMINAL_ID,
263 cwd: "/tmp/project",
264 worktreePath: null,
265 status: "running",
266 pid: 1234,
267 history: "hello\n",
268 exitCode: null,
269 exitSignal: null,
270 label: "Primary",
271 updatedAt: isoTimestamp,
272 }),
273 ).toBe(true);
274 });
275});
276
277describe("TerminalEvent", () => {
278 const isoTimestamp = "2026-01-01T00:00:00.000Z";
279
280 it("accepts output events", () => {
281 expect(
282 decodes(TerminalEvent, {
283 type: "output",
284 threadId: "thread-1",
285 terminalId: DEFAULT_TERMINAL_ID,
286 data: "line\n",
287 }),
288 ).toBe(true);
289 });
290
291 it("accepts exited events", () => {
292 expect(
293 decodes(TerminalEvent, {
294 type: "exited",
295 threadId: "thread-1",
296 terminalId: DEFAULT_TERMINAL_ID,
297 exitCode: 0,
298 exitSignal: null,
299 }),
300 ).toBe(true);
301 });
302
303 it("accepts closed events", () => {
304 expect(
305 decodes(TerminalEvent, {
306 type: "closed",
307 threadId: "thread-1",
308 terminalId: DEFAULT_TERMINAL_ID,
309 }),
310 ).toBe(true);
311 });
312
313 it("accepts activity events", () => {
314 expect(
315 decodes(TerminalEvent, {
316 type: "activity",
317 threadId: "thread-1",
318 terminalId: DEFAULT_TERMINAL_ID,
319 hasRunningSubprocess: true,
320 label: "vim",
321 }),
322 ).toBe(true);
323 });
324
325 it("accepts started events with snapshot worktree metadata", () => {
326 expect(
327 decodes(TerminalEvent, {
328 type: "started",
329 threadId: "thread-1",
330 terminalId: DEFAULT_TERMINAL_ID,
331 snapshot: {
332 threadId: "thread-1",
333 terminalId: DEFAULT_TERMINAL_ID,
334 cwd: "/tmp/project/.t3/worktrees/feature-a",
335 worktreePath: "/tmp/project/.t3/worktrees/feature-a",
336 status: "running",
337 pid: 1234,
338 history: "",
339 exitCode: null,
340 exitSignal: null,
341 label: "Primary",
342 updatedAt: isoTimestamp,
343 },
344 }),
345 ).toBe(true);
346 });
347});