1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { assert, it, vi } from "@effect/vitest";
3import { ProjectId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Layer from "effect/Layer";
7import * as Option from "effect/Option";
8
9import * as TerminalManager from "../terminal/Manager.ts";
10import * as ServerSettings from "../serverSettings.ts";
11import * as ProjectService from "./ProjectService.ts";
12import * as ProjectSetupScriptRunner from "./ProjectSetupScriptRunner.ts";
13
14it.effect("resolves setup scripts through the standalone project service", () => {
15 const open = vi.fn((input: Parameters<TerminalManager.TerminalManager["Service"]["open"]>[0]) =>
16 Effect.succeed({
17 threadId: input.threadId,
18 terminalId: input.terminalId,
19 cwd: input.cwd,
20 worktreePath: input.worktreePath ?? null,
21 status: "running" as const,
22 pid: 123,
23 history: "",
24 exitCode: null,
25 exitSignal: null,
26 label: "Shell",
27 updatedAt: "2026-06-20T00:00:00.000Z",
28 }),
29 );
30 const write = vi.fn(
31 (_input: Parameters<TerminalManager.TerminalManager["Service"]["write"]>[0]) => Effect.void,
32 );
33 const closeIdle = vi.fn(
34 (_input: Parameters<TerminalManager.TerminalManager["Service"]["closeIdle"]>[0]) => Effect.void,
35 );
36 const listeners: Array<Parameters<TerminalManager.TerminalManager["Service"]["subscribe"]>[0]> =
37 [];
38 const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) =>
39 Effect.sync(() => {
40 listeners.push(listener);
41 return () => undefined;
42 });
43 const projectId = ProjectId.make("project:setup-runner-v2");
44 const project = {
45 id: projectId,
46 title: "Project",
47 workspaceRoot: "/repo",
48 repositoryIdentity: null,
49 faviconPath: null,
50 defaultModelSelection: null,
51 scripts: [
52 {
53 id: "setup",
54 name: "Setup",
55 command: "vp install",
56 icon: "configure" as const,
57 runOnWorktreeCreate: true,
58 },
59 {
60 id: "clean",
61 name: "Clean",
62 command: "cargo clean",
63 icon: "build" as const,
64 runOnWorktreeCreate: false,
65 runOnSettle: true,
66 },
67 ],
68 createdAt: "2026-06-20T00:00:00.000Z",
69 updatedAt: "2026-06-20T00:00:00.000Z",
70 deletedAt: null,
71 };
72 const layer = ProjectSetupScriptRunner.layer.pipe(
73 Layer.provide(
74 Layer.mergeAll(
75 Layer.mock(ProjectService.ProjectService)({
76 getById: () => Effect.succeed(Option.some(project)),
77 }),
78 Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe, closeIdle }),
79 ServerSettings.layerTest(),
80 NodeCrypto.layer,
81 ),
82 ),
83 );
84
85 return Effect.gen(function* () {
86 const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
87 const result = yield* runner.runForThread({
88 threadId: "thread-1",
89 projectId,
90 worktreePath: "/repo-worktree",
91 });
92 assert.deepEqual(result, {
93 status: "started",
94 async: true,
95 scriptId: "setup",
96 scriptName: "Setup",
97 scriptCommand: "vp install",
98 terminalId: "setup-setup",
99 cwd: "/repo-worktree",
100 });
101 assert.equal(open.mock.calls[0]?.[0].cwd, "/repo-worktree");
102 assert.deepEqual(open.mock.calls[0]?.[0].env, {
103 T3CODE_PROJECT_ROOT: "/repo",
104 T3CODE_WORKTREE_PATH: "/repo-worktree",
105 COLORTERM: "",
106 NO_COLOR: "1",
107 FORCE_COLOR: "0",
108 });
109 assert.equal(write.mock.calls[0]?.[0].data, "vp install\r");
110 const lines: string[] = [];
111 const observed = yield* runner.runForThread({
112 threadId: "thread-1",
113 projectId,
114 worktreePath: "/repo-worktree",
115 observeCompletion: {
116 onOutputLine: (line) =>
117 Effect.sync(() => {
118 lines.push(line);
119 }),
120 },
121 });
122 assert.equal(observed.status, "started");
123 const listener = listeners[0]!;
124 yield* listener({
125 type: "output",
126 threadId: "thread-1",
127 terminalId: "setup-setup",
128 data: "Downloading 10%\rDownloading 20%\r\nDone\n",
129 });
130 assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]);
131 yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" });
132
133 const settle = yield* runner.runForThread({
134 threadId: "thread-1",
135 projectId,
136 worktreePath: "/repo-worktree",
137 trigger: "settle",
138 });
139 const settleTerminalId = settle.status === "started" ? settle.terminalId : "";
140 assert.match(settleTerminalId, /^settle-clean-/);
141 assert.equal(write.mock.calls.at(-1)?.[0].data, "cargo clean\r");
142
143 // A clean run closes its shell once the prompt is back, not at the
144 // sentinel, so the prompt redraw is not taken for new activity.
145 const observedSettle = yield* runner.runForThread({
146 threadId: "thread-1",
147 projectId,
148 worktreePath: "/repo-worktree",
149 trigger: "settle",
150 observeCompletion: {},
151 });
152 const observedTerminalId = observedSettle.status === "started" ? observedSettle.terminalId : "";
153 // Each settle gets its own shell, so a busy one is never typed into.
154 assert.notEqual(observedTerminalId, settleTerminalId);
155 const token = /__T3_SETUP_DONE___(\w+):/.exec(write.mock.calls.at(-1)?.[0].data ?? "")?.[1];
156 const settleListener = listeners.at(-1)!;
157 const completion = yield* Effect.forkChild(
158 observedSettle.status === "started" && observedSettle.completion
159 ? observedSettle.completion
160 : Effect.die("no completion"),
161 );
162 yield* settleListener({
163 type: "output",
164 threadId: "thread-1",
165 terminalId: observedTerminalId,
166 data: `\r\n__T3_SETUP_DONE___${token}:0\r\n`,
167 });
168 yield* Effect.yieldNow;
169 assert.equal(closeIdle.mock.calls.length, 0);
170 yield* settleListener({
171 type: "output",
172 threadId: "thread-1",
173 terminalId: observedTerminalId,
174 data: "$ ",
175 });
176 assert.deepEqual((yield* Fiber.join(completion)).exitCode, 0);
177 assert.deepEqual(closeIdle.mock.calls[0]?.[0], {
178 threadId: "thread-1",
179 terminalId: observedTerminalId,
180 });
181 }).pipe(Effect.provide(layer));
182});