1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6import * as Result from "effect/Result";
7
8import * as HostProcess from "./HostProcess.ts";
9import { resolveNodeExecutable, resolveSelfInvocation, selfInvocationArgs } from "./nodeRuntime.ts";
10import { symlinksSupported } from "./testing/symlinks.ts";
11
12describe("Self invocation", () => {
13 it.effect("runs the entrypoint script with the current runtime", () =>
14 Effect.gen(function* () {
15 const path = yield* Path.Path;
16 const invocation = yield* resolveSelfInvocation().pipe(
17 Effect.provideService(HostProcess.ExecutablePath, "/runtime/node"),
18 Effect.provideService(HostProcess.IsExecutable, false),
19 Effect.provideService(HostProcess.Arguments, ["/runtime/node", "dist/bin.mjs", "serve"]),
20 );
21 expect(invocation.command).toBe("/runtime/node");
22 expect(invocation.entrypoint).toBe(path.resolve("dist/bin.mjs"));
23 expect(selfInvocationArgs(invocation, ["acp-mcp-bridge"])).toEqual([
24 path.resolve("dist/bin.mjs"),
25 "acp-mcp-bridge",
26 ]);
27 }).pipe(Effect.provide(NodeServices.layer)),
28 );
29
30 it.effect("passes subcommands straight to the standalone executable", () =>
31 Effect.gen(function* () {
32 // Node repeats the binary at argv[1] for a single-executable; it is not a script.
33 const invocation = yield* resolveSelfInvocation().pipe(
34 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
35 Effect.provideService(HostProcess.IsExecutable, true),
36 Effect.provideService(HostProcess.Arguments, ["/packaged/t3", "/packaged/t3", "serve"]),
37 );
38 expect(invocation).toEqual({ command: "/packaged/t3", entrypoint: undefined });
39 expect(selfInvocationArgs(invocation, ["acp-mcp-bridge"])).toEqual(["acp-mcp-bridge"]);
40 }).pipe(Effect.provide(NodeServices.layer)),
41 );
42});
43
44describe("Node runtime selection", () => {
45 it.effect("keeps the current Node or Electron runtime without requiring Node on PATH", () =>
46 Effect.gen(function* () {
47 for (const executable of ["/runtime/node", "/Applications/T3 Code.app/Electron"]) {
48 expect(
49 yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe(
50 Effect.provideService(HostProcess.ExecutablePath, executable),
51 Effect.provideService(HostProcess.IsExecutable, false),
52 ),
53 ).toBe(executable);
54 }
55 }).pipe(Effect.provide(NodeServices.layer)),
56 );
57
58 it.effect("uses installed Node instead of the standalone T3 executable", () =>
59 Effect.gen(function* () {
60 const path = yield* Path.Path;
61 expect(
62 yield* resolveNodeExecutable("Local device support", {
63 PATH: path.dirname(process.execPath),
64 }),
65 ).toBe(process.execPath);
66 }).pipe(
67 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
68 Effect.provideService(HostProcess.IsExecutable, true),
69 Effect.provide(NodeServices.layer),
70 ),
71 );
72
73 it.effect("explains how to install Node when a standalone helper has no runtime", () =>
74 Effect.gen(function* () {
75 const error = yield* resolveNodeExecutable("Local device support", { PATH: "" }).pipe(
76 Effect.flip,
77 );
78 expect(error._tag).toBe("NodeRuntimeUnavailableError");
79 expect(error.message).toContain("Local device support requires Node.js");
80 expect(error.message).toContain("Install Node.js");
81 }).pipe(
82 Effect.provideService(HostProcess.IsExecutable, true),
83 Effect.provide(NodeServices.layer),
84 ),
85 );
86
87 it.effect("finds a newly installed runtime immediately after a failed lookup", () =>
88 Effect.gen(function* () {
89 const fs = yield* FileSystem.FileSystem;
90 const path = yield* Path.Path;
91 const directory = yield* fs.makeTempDirectoryScoped();
92 const platform = yield* HostProcess.Platform;
93 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
94 const env = { PATH: directory };
95 expect(
96 Result.isFailure(
97 yield* resolveNodeExecutable("Local device support", env).pipe(Effect.result),
98 ),
99 ).toBe(true);
100 yield* fs.copyFile(process.execPath, node);
101 yield* fs.chmod(node, 0o755);
102 expect(yield* resolveNodeExecutable("Local device support", env)).toBe(node);
103 }).pipe(
104 Effect.scoped,
105 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
106 Effect.provideService(HostProcess.IsExecutable, true),
107 Effect.provide(NodeServices.layer),
108 ),
109 );
110
111 it.effect("uses node.exe even when Windows batch wrappers appear first on PATH", () =>
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const directory = yield* fs.makeTempDirectoryScoped();
116 const wrappers = path.join(directory, "wrappers");
117 const runtime = path.join(directory, "runtime");
118 yield* fs.makeDirectory(wrappers);
119 yield* fs.makeDirectory(runtime);
120 yield* fs.writeFileString(path.join(wrappers, "node.cmd"), "@echo off");
121 yield* fs.writeFileString(path.join(wrappers, "node.bat"), "@echo off");
122 const node = path.join(runtime, "node.exe");
123 yield* fs.copyFile(process.execPath, node);
124 expect(
125 yield* resolveNodeExecutable("Local device support", {
126 PATH: `${wrappers};${runtime}`,
127 PATHEXT: ".CMD;.BAT",
128 }),
129 ).toBe(node);
130 }).pipe(
131 Effect.scoped,
132 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
133 Effect.provideService(HostProcess.IsExecutable, true),
134 Effect.provideService(HostProcess.Platform, "win32"),
135 Effect.provide(NodeServices.layer),
136 ),
137 );
138
139 it.effect("reports install guidance when Windows only has batch runtime wrappers", () =>
140 Effect.gen(function* () {
141 const fs = yield* FileSystem.FileSystem;
142 const path = yield* Path.Path;
143 const directory = yield* fs.makeTempDirectoryScoped();
144 yield* fs.writeFileString(path.join(directory, "node.cmd"), "@echo off");
145 yield* fs.writeFileString(path.join(directory, "node.bat"), "@echo off");
146 const error = yield* resolveNodeExecutable("Local device support", {
147 PATH: directory,
148 PATHEXT: ".CMD;.BAT;.EXE",
149 }).pipe(Effect.flip);
150 expect(error._tag).toBe("NodeRuntimeUnavailableError");
151 expect(error.message).toContain("Install Node.js");
152 }).pipe(
153 Effect.scoped,
154 Effect.provideService(HostProcess.IsExecutable, true),
155 Effect.provideService(HostProcess.Platform, "win32"),
156 Effect.provide(NodeServices.layer),
157 ),
158 );
159
160 it.effect("rejects a hard-linked node alias pointing back at the standalone app", () =>
161 Effect.gen(function* () {
162 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164 const directory = yield* fs.makeTempDirectoryScoped();
165 const platform = yield* HostProcess.Platform;
166 const executable = path.join(directory, platform === "win32" ? "t3.exe" : "t3");
167 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
168 yield* fs.writeFileString(executable, "standalone executable fixture");
169 yield* fs.chmod(executable, 0o755);
170 yield* fs.link(executable, node);
171 const error = yield* resolveNodeExecutable("Local device support", { PATH: directory }).pipe(
172 Effect.provideService(HostProcess.ExecutablePath, executable),
173 Effect.flip,
174 );
175 expect(error._tag).toBe("NodeRuntimeUnavailableError");
176 expect(error.message).toContain("Install Node.js");
177 }).pipe(
178 Effect.scoped,
179 Effect.provideService(HostProcess.IsExecutable, true),
180 Effect.provide(NodeServices.layer),
181 ),
182 );
183
184 it.effect.skipIf(!symlinksSupported)("preserves the node alias used by runtime launchers", () =>
185 Effect.gen(function* () {
186 const fs = yield* FileSystem.FileSystem;
187 const path = yield* Path.Path;
188 const directory = yield* fs.makeTempDirectoryScoped();
189 const platform = yield* HostProcess.Platform;
190 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
191 yield* fs.symlink(process.execPath, node);
192 expect(yield* resolveNodeExecutable("Local device support", { PATH: directory })).toBe(node);
193 }).pipe(
194 Effect.scoped,
195 Effect.provideService(HostProcess.ExecutablePath, "/packaged/t3"),
196 Effect.provideService(HostProcess.IsExecutable, true),
197 Effect.provide(NodeServices.layer),
198 ),
199 );
200
201 it.effect.skipIf(!symlinksSupported)(
202 "rejects a node alias pointing back at the standalone app",
203 () =>
204 Effect.gen(function* () {
205 const fs = yield* FileSystem.FileSystem;
206 const path = yield* Path.Path;
207 const directory = yield* fs.makeTempDirectoryScoped();
208 const platform = yield* HostProcess.Platform;
209 const node = path.join(directory, platform === "win32" ? "node.exe" : "node");
210 yield* fs.symlink(process.execPath, node);
211 const error = yield* resolveNodeExecutable("Local device support", {
212 PATH: directory,
213 }).pipe(Effect.flip);
214 expect(error.message).toContain("Install Node.js");
215 }).pipe(
216 Effect.scoped,
217 Effect.provideService(HostProcess.IsExecutable, true),
218 Effect.provide(NodeServices.layer),
219 ),
220 );
221});