1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { describe, expect, it } from "@effect/vitest";
3import {
4 ProjectId,
5 TerminalNotRunningError,
6 type TerminalEvent,
7 type TerminalSessionSnapshot,
8} from "@t3tools/contracts";
9import * as Deferred from "effect/Deferred";
10import * as Effect from "effect/Effect";
11import * as Fiber from "effect/Fiber";
12import * as Layer from "effect/Layer";
13import * as Queue from "effect/Queue";
14import { TestClock } from "effect/testing";
15
16import * as ServerSettings from "../serverSettings.ts";
17import * as TerminalManager from "../terminal/Manager.ts";
18import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
19import * as ProjectService from "./ProjectService.ts";
20import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
21
22const threadId = "thread-1";
23const terminalId = "setup-setup";
24const event = { threadId, terminalId, sequence: 1, createdAt: "2026-01-01T00:00:00.000Z" };
25
26const openSnapshot = (history: string): TerminalSessionSnapshot => ({
27 threadId,
28 terminalId,
29 cwd: "/repo/.t3/worktrees/feature",
30 worktreePath: null,
31 status: "running",
32 pid: 123,
33 history,
34 exitCode: null,
35 exitSignal: null,
36 label: "zsh",
37 updatedAt: "2026-01-01T00:00:00.000Z",
38});
39
40const makeHarness = Effect.fn(function* (options: {
41 readonly command?: string;
42 readonly history?: string;
43 readonly opening?: Deferred.Deferred<void>;
44 readonly shell?: string | null;
45}) {
46 const listeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
47 const writes = yield* Queue.unbounded<string>();
48 const outputLines = yield* Queue.unbounded<string>();
49 const terminals = Layer.mock(TerminalManager.TerminalManager)({
50 open: () =>
51 (options.opening
52 ? Deferred.succeed(options.opening, undefined).pipe(Effect.andThen(Effect.never))
53 : Effect.void
54 ).pipe(Effect.as(openSnapshot(options.history ?? ""))),
55 getShell: () =>
56 options.shell === null
57 ? Effect.fail(new TerminalNotRunningError({ threadId, terminalId }))
58 : Effect.succeed(options.shell ?? "/bin/zsh"),
59 subscribe: (listener) =>
60 Effect.sync(() => {
61 listeners.add(listener);
62 return () => listeners.delete(listener);
63 }),
64 write: (input) => Queue.offer(writes, input.data).pipe(Effect.asVoid),
65 closeIdle: () => Effect.void,
66 closeOnSettle: () => Effect.void,
67 });
68 const runner = yield* ProjectSetupScriptRunner.make.pipe(
69 Effect.provide(
70 Layer.mergeAll(
71 terminals,
72 Layer.mock(ProjectService.ProjectService)({}),
73 Layer.mock(ProjectionStore.ProjectionStoreV2)({
74 getThreadShell: () => Effect.succeed(null),
75 }),
76 ServerSettings.layerTest(),
77 NodeCrypto.layer,
78 ),
79 ),
80 );
81 const start = runner.runForThread({
82 threadId,
83 worktreePath: "/repo/.t3/worktrees/feature",
84 observeCompletion: { onOutputLine: (line) => Queue.offer(outputLines, line) },
85 project: {
86 id: ProjectId.make("project-1"),
87 workspaceRoot: "/repo",
88 scripts: [
89 {
90 id: "setup",
91 name: "Setup",
92 command: options.command ?? "vp i",
93 icon: "configure",
94 runOnWorktreeCreate: true,
95 },
96 ],
97 },
98 });
99 const emit = (terminalEvent: TerminalEvent) =>
100 Effect.forEach([...listeners], (listener) => listener(terminalEvent), { discard: true });
101 return { start, listeners, writes, outputLines, emit };
102});
103
104const setup = Effect.fn(function* (
105 options: {
106 readonly command?: string;
107 readonly history?: string;
108 readonly shell?: string | null;
109 } = {},
110) {
111 const harness = yield* makeHarness(options);
112 const result = yield* harness.start;
113 const completion = result.status === "started" ? result.completion : undefined;
114 if (!completion) return yield* Effect.die("setup did not start an observed run");
115 return { ...harness, result, completion };
116});
117
118describe("ProjectSetupScriptRunner", () => {
119 it.effect("writes immediately when the spawned fallback is PowerShell", () =>
120 Effect.gen(function* () {
121 const { writes } = yield* setup({
122 shell: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
123 });
124 expect(yield* Queue.take(writes)).toContain("$global:LASTEXITCODE");
125 }),
126 );
127
128 it.effect("types an escaped Nushell wrapper once its line editor starts", () =>
129 Effect.gen(function* () {
130 const command =
131 'print "quoted"; print \'C:\\tools\'; print "$env.PATH"\nexit 7 # comment\r\n\t';
132 const escaped =
133 'print \\"quoted\\"; print \'C:\\\\tools\'; print \\"$env.PATH\\"\\u{a}exit 7 # comment\\u{d}\\u{a}\\u{9}';
134 const { writes, emit } = yield* setup({ shell: "/opt/homebrew/bin/nu", command });
135 yield* TestClock.adjust("1 second");
136 expect(yield* Queue.size(writes)).toBe(0);
137 yield* emit({ ...event, type: "output", data: "> \x1b[?2004h" });
138 const written = yield* Queue.take(writes);
139 const sentinel = written.match(/__T3_SETUP_DONE___[0-9a-f]{32}:/)?.[0];
140 expect(sentinel).toBeDefined();
141 expect(written).toBe(
142 ` ${[
143 "$env.LAST_EXIT_CODE = 0",
144 "let __t3f = try { mktemp --tmpdir-path $env.T3CODE_WORKTREE_PATH --suffix .nu .t3-setup-XXXXXXXX } catch {|e| print -e $e.msg; null }",
145 `if $__t3f == null { $env.LAST_EXIT_CODE = 1 } else { try { [$"rm --permanent ($__t3f | to nuon)" "${escaped}"] | str join (char newline) | save --force --raw $__t3f; ^$nu.current-exe --login --commands $"cd $env.T3CODE_WORKTREE_PATH; source ($__t3f | to nuon)" } catch {|e| if $e != null and $e.exit_code? == null { print -e $e.msg }; if $env.LAST_EXIT_CODE == 0 { $env.LAST_EXIT_CODE = 1 } } }`,
146 "let __t3c = $env.LAST_EXIT_CODE",
147 "try { if $__t3f != null and ($__t3f | path exists) { rm --permanent $__t3f } } catch {|e| print -e $e.msg }",
148 `print $"\\n${sentinel}($__t3c)"`,
149 ].join("; ")}\r`,
150 );
151 }),
152 );
153
154 it.effect("shows a startup prompt and types only once the line editor starts", () =>
155 Effect.gen(function* () {
156 const { result, writes, outputLines, emit } = yield* setup();
157 expect(result).toMatchObject({ status: "started", terminalId });
158
159 yield* emit({ ...event, type: "output", data: "Missing Node.\r\nInstall it? [y/N]: " });
160 yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" });
161 yield* TestClock.adjust("1 minute");
162 expect(yield* Queue.size(writes)).toBe(0);
163 expect(yield* Queue.takeAll(outputLines)).toEqual(["Missing Node.", "Install it? [y/N]:"]);
164
165 yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" });
166 yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" });
167 expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
168 }),
169 );
170
171 it.effect("types into a quiet shell that has no line editor", () =>
172 Effect.gen(function* () {
173 const { writes } = yield* setup();
174 yield* TestClock.adjust("3 seconds");
175 expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
176 }),
177 );
178
179 it.effect("ignores line editor state restored from an earlier shell", () =>
180 Effect.gen(function* () {
181 const { writes, emit } = yield* setup({ history: "old run\r\n% \x1b[?2004h" });
182 yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" });
183 yield* TestClock.adjust("1 minute");
184 expect(yield* Queue.size(writes)).toBe(0);
185 }),
186 );
187
188 it.effect("forgets a busy startup child when the shell restarts", () =>
189 Effect.gen(function* () {
190 const { writes, emit } = yield* setup();
191 yield* emit({ ...event, type: "activity", hasRunningSubprocess: true, label: "fnm" });
192 yield* TestClock.adjust("1 minute");
193 expect(yield* Queue.size(writes)).toBe(0);
194 yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") });
195 // The restart lands inside a quiet window, so the next full window decides.
196 yield* TestClock.adjust("6 seconds");
197 expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/);
198 }),
199 );
200
201 it.effect("drops its terminal listeners when cancelled while the terminal opens", () =>
202 Effect.gen(function* () {
203 const opening = yield* Deferred.make<void>();
204 const { start, listeners } = yield* makeHarness({ opening });
205 const fiber = yield* Effect.forkChild(start);
206 yield* Deferred.await(opening);
207 expect(listeners.size).toBe(2);
208 yield* Fiber.interrupt(fiber);
209 expect(listeners.size).toBe(0);
210 }),
211 );
212
213 it.effect.each([false, true])(
214 "does not type into a shell that exits during startup (before shell lookup: %s)",
215 (beforeShellLookup) =>
216 Effect.gen(function* () {
217 const { completion, writes, emit, listeners } = yield* setup(
218 beforeShellLookup ? { shell: null } : {},
219 );
220 if (!beforeShellLookup) {
221 yield* emit({ ...event, type: "exited", exitCode: 1, exitSignal: null });
222 }
223 expect(yield* completion).toMatchObject({ exitCode: null });
224 yield* TestClock.adjust("1 minute");
225 expect(yield* Queue.size(writes)).toBe(0);
226 expect(listeners.size).toBe(0);
227 }),
228 );
229});