Skip to content
Source
Sign inSign up

invocation.test.ts

356 lines15.9 KB

1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7​
8import packageJson from "../../package.json" with { type: "json" };
9import {
10 resolveHostCliCommand,
11 resolveRootCliCommand,
12 resolveServerInstallation,
13} from "./invocation.ts";
14​
15it.layer(NodeServices.layer)("root CLI commands", (it) => {
16 const rootCommand = (input: {
17 readonly node: string;
18 readonly entry: string;
19 readonly path?: string;
20 readonly env?: Record<string, string>;
21 readonly executable?: boolean;
22 readonly args?: ReadonlyArray<string>;
23 }) =>
24 resolveRootCliCommand("browser setup", input.args).pipe(
25 Effect.provideService(HostProcess.ExecutablePath, input.node),
26 Effect.provideService(HostProcess.Arguments, [input.node, input.entry]),
27 Effect.provideService(HostProcess.IsExecutable, input.executable ?? false),
28 Effect.provideService(HostProcess.Platform, "linux"),
29 Effect.provideService(HostProcess.Environment, { PATH: input.path ?? "", ...input.env }),
30 );
31​
32 const pathWithT3 = Effect.gen(function* () {
33 const fs = yield* FileSystem.FileSystem;
34 const path = yield* Path.Path;
35 const bin = yield* fs.makeTempDirectoryScoped();
36 yield* fs.writeFileString(path.join(bin, "t3"), "#!/bin/sh\n", { mode: 0o755 });
37 return bin;
38 });
39​
40 it.effect("keeps a user-installed Node reachable when the command runs under sudo", () =>
41 Effect.gen(function* () {
42 const npx = "/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs";
43 expect(yield* rootCommand({ node: "/usr/bin/node", entry: npx })).toBe(
44 "sudo npx t3 browser setup",
45 );
46 expect(
47 yield* rootCommand({ node: "/home/theo/.nvm/versions/node/v24/bin/node", entry: npx }),
48 ).toBe('sudo env "PATH=$PATH" npx t3 browser setup');
49 expect(
50 yield* rootCommand({
51 node: "/home/theo/.local/node/bin/node",
52 entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs",
53 path: yield* pathWithT3,
54 }),
55 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
56 }),
57 );
58​
59 it.effect("keeps the arguments, so root runs setup on the same T3 home", () =>
60 Effect.gen(function* () {
61 const args = ["--base-dir", "/srv/t3 home"];
62 expect(
63 yield* rootCommand({
64 node: "/home/theo/.local/node/bin/node",
65 entry: "/home/theo/.local/lib/node_modules/t3/dist/bin.mjs",
66 path: yield* pathWithT3,
67 args,
68 }),
69 ).toBe(`sudo env "PATH=$PATH" t3 browser setup --base-dir '/srv/t3 home'`);
70 expect(
71 yield* rootCommand({ node: "/opt/t3/t3", entry: "/opt/t3/t3", executable: true, args }),
72 ).toBe("sudo /opt/t3/t3 browser setup --base-dir '/srv/t3 home'");
73 }),
74 );
75​
76 it.effect("names this install's launcher when t3 is not on PATH", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 const path = yield* Path.Path;
80 const home = yield* fs.makeTempDirectoryScoped();
81 const shim = path.join(home, ".local bin", "t3");
82 yield* fs.makeDirectory(path.dirname(shim), { recursive: true });
83 yield* fs.writeFileString(shim, "#!/bin/sh\n", { mode: 0o755 });
84 const desktop = {
85 node: "/tmp/.mount_T3abc/t3code",
86 entry: "/tmp/.mount_T3abc/resources/app.asar/apps/server/dist/bin.mjs",
87 env: { T3CODE_CLI_PATH: shim },
88 };
89​
90 expect(yield* rootCommand(desktop)).toBe(`sudo '${shim}' browser setup`);
91​
92 // An unrelated `t3` on PATH, such as an older npm global, is another install's CLI.
93 expect(yield* rootCommand({ ...desktop, path: yield* pathWithT3 })).toBe(
94 `sudo '${shim}' browser setup`,
95 );
96​
97 // The link the app installs onto PATH leads to its own launcher, so plain `t3` is right.
98 const linked = yield* fs.makeTempDirectoryScoped();
99 yield* fs.symlink(shim, path.join(linked, "t3"));
100 expect(yield* rootCommand({ ...desktop, path: linked })).toBe(
101 'sudo env "PATH=$PATH" t3 browser setup',
102 );
103​
104 expect(
105 yield* rootCommand({
106 node: "/opt/t3/t3",
107 entry: "/opt/t3/t3",
108 executable: true,
109 }),
110 ).toBe("sudo /opt/t3/t3 browser setup");
111​
112 // A standalone executable started directly (systemd, no launcher) is still this install.
113 expect(
114 yield* rootCommand({
115 node: "/opt/t3/t3",
116 entry: "/opt/t3/t3",
117 executable: true,
118 path: yield* pathWithT3,
119 }),
120 ).toBe("sudo /opt/t3/t3 browser setup");
121​
122 // A global npm launcher runs the downloaded executable; PATH's `t3` is that launcher.
123 const prefix = path.join(home, "npm");
124 const npmLauncher = path.join(prefix, "lib/node_modules/t3/bin/t3.mjs");
125 yield* fs.makeDirectory(path.dirname(npmLauncher), { recursive: true });
126 yield* fs.makeDirectory(path.join(prefix, "bin"));
127 yield* fs.writeFileString(npmLauncher, "", { mode: 0o755 });
128 yield* fs.writeFileString(
129 path.join(prefix, "lib/node_modules/t3/package.json"),
130 `{"name":"t3","version":"${packageJson.version}"}`,
131 );
132 yield* fs.symlink(npmLauncher, path.join(prefix, "bin/t3"));
133 expect(
134 yield* rootCommand({
135 node: "/home/theo/.t3/runtime/versions/1.0.0/t3",
136 entry: "/home/theo/.t3/runtime/versions/1.0.0/t3",
137 executable: true,
138 path: path.join(prefix, "bin"),
139 env: { T3CODE_NPM_LAUNCHER_PATH: npmLauncher },
140 }),
141 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
142​
143 expect(
144 yield* rootCommand({ ...desktop, env: { T3CODE_CLI_PATH: path.join(home, "gone") } }),
145 ).toBe('sudo env "PATH=$PATH" t3 browser setup');
146 }),
147 );
148});
149​
150it.layer(NodeServices.layer)("host CLI commands", (it) => {
151 it.effect("renders a PowerShell command line on Windows", () =>
152 Effect.gen(function* () {
153 const fs = yield* FileSystem.FileSystem;
154 const path = yield* Path.Path;
155 const home = yield* fs.makeTempDirectoryScoped();
156 const shim = path.join(home, "t3.cmd");
157 yield* fs.writeFileString(shim, "@echo off\r\n");
158 const hostCommand = (platform: NodeJS.Platform, env: Record<string, string>) =>
159 resolveHostCliCommand("doctor", ["--base-dir", "C:\\Users\\Jane Doe\\.t3"]).pipe(
160 Effect.provideService(HostProcess.ExecutablePath, "C:\\Program Files\\T3 Code\\t3.exe"),
161 Effect.provideService(HostProcess.Arguments, [
162 "C:\\Program Files\\T3 Code\\t3.exe",
163 "C:\\Program Files\\T3 Code\\resources\\app.asar\\apps\\server\\dist\\bin.mjs",
164 ]),
165 Effect.provideService(HostProcess.IsExecutable, false),
166 Effect.provideService(HostProcess.Platform, platform),
167 Effect.provideService(HostProcess.Environment, { PATH: "", ...env }),
168 );
169 expect((yield* hostCommand("win32", { T3CODE_CLI_PATH: shim })).command).toBe(
170 `& "${shim}" doctor "--base-dir" "C:\\Users\\Jane Doe\\.t3"`,
171 );
172 expect((yield* hostCommand("win32", {})).command).toBe(
173 't3 doctor "--base-dir" "C:\\Users\\Jane Doe\\.t3"',
174 );
175 expect(
176 (yield* resolveHostCliCommand("doctor", ["--base-dir", "C:\\T3\\\u201CArchive\u201D"]).pipe(
177 Effect.provideService(HostProcess.Platform, "win32"),
178 Effect.provideService(HostProcess.Environment, { PATH: "" }),
179 Effect.provideService(HostProcess.ExecutablePath, "C:\\t3.exe"),
180 Effect.provideService(HostProcess.Arguments, ["C:\\t3.exe", "C:\\bin.mjs"]),
181 Effect.provideService(HostProcess.IsExecutable, false),
182 )).command,
183 ).toBe('t3 doctor "--base-dir" "C:\\T3\\\u201C\u201CArchive\u201D\u201D"');
184 expect((yield* hostCommand("linux", { T3CODE_CLI_PATH: shim })).command).toMatch(
185 / doctor --base-dir 'C:\\Users\\Jane Doe\\\.t3'$/,
186 );
187 }),
188 );
189});
190​
191it.layer(NodeServices.layer)("manual server installation ownership", (it) => {
192 it.effect("recognizes runner caches for script packages and the npm launcher", () =>
193 Effect.gen(function* () {
194 const fs = yield* FileSystem.FileSystem;
195 const path = yield* Path.Path;
196 const root = yield* fs.makeTempDirectoryScoped();
197 const executable = path.join(root, ".t3/runtime/versions/1.0.0/t3");
198 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
199 yield* fs.writeFileString(executable, "");
200 for (const [relative, kind] of [
201 ["npm/_npx/hash/node_modules/t3/dist/bin.mjs", "npx"],
202 ["npm/_npx/hash/node_modules/t3/bin/t3.mjs", "npx"],
203 ["pnpm/dlx/hash/node_modules/t3/dist/bin.mjs", "pnpm-dlx"],
204 [".bun/install/cache/t3/dist/bin.mjs", "bunx"],
205 ] as const) {
206 const entry = path.join(root, relative);
207 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
208 yield* fs.writeFileString(entry, "");
209 yield* fs.writeFileString(
210 path.join(path.dirname(entry), "../package.json"),
211 `{"name":"t3","version":"${packageJson.version}","bin":{"t3":"./bin/t3.mjs"}}`,
212 );
213 const launchedBy = entry.endsWith("t3.mjs");
214 const installation = yield* resolveServerInstallation.pipe(
215 Effect.provideService(HostProcess.Arguments, [launchedBy ? executable : "node", entry]),
216 Effect.provideService(HostProcess.ExecutablePath, executable),
217 Effect.provideService(HostProcess.IsExecutable, launchedBy),
218 Effect.provideService(
219 HostProcess.Environment,
220 launchedBy ? { T3CODE_NPM_LAUNCHER_PATH: entry } : {},
221 ),
222 );
223 expect(installation).toEqual({ kind });
224 }
225 }),
226 );
227​
228 it.effect("requires the npm prefix's bin to point to the running package", () =>
229 Effect.gen(function* () {
230 const fs = yield* FileSystem.FileSystem;
231 const path = yield* Path.Path;
232 const root = yield* fs.makeTempDirectoryScoped();
233 const prefix = path.join(root, "bunx-tools");
234 const packageRoot = path.join(prefix, "lib/node_modules/t3");
235 const entry = path.join(packageRoot, "dist/bin.mjs");
236 const globalBin = path.join(prefix, "bin/t3");
237 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
238 yield* fs.makeDirectory(path.dirname(globalBin), { recursive: true });
239 yield* fs.writeFileString(entry, "");
240 yield* fs.writeFileString(
241 path.join(packageRoot, "package.json"),
242 '{"name":"t3","version":"0.0.45","bin":{"t3":"./dist/bin.mjs"}}',
243 );
244 const resolve = resolveServerInstallation.pipe(
245 Effect.provideService(HostProcess.Arguments, ["node", entry]),
246 Effect.provideService(HostProcess.IsExecutable, false),
247 Effect.provideService(HostProcess.Platform, "linux"),
248 );
249 expect(yield* resolve).toBeNull();
250 yield* fs.symlink(entry, globalBin);
251 expect(yield* resolve).toEqual({ kind: "npm-global", prefix });
252 yield* fs.remove(globalBin);
253 yield* fs.writeFileString(globalBin, "an unrelated t3 command");
254 expect(yield* resolve).toBeNull();
255 expect(yield* resolve.pipe(Effect.provideService(HostProcess.Platform, "win32"))).toBeNull();
256 }),
257 );
258​
259 it.effect("proves the downloaded executable was started by a global npm launcher", () =>
260 Effect.gen(function* () {
261 const fs = yield* FileSystem.FileSystem;
262 const path = yield* Path.Path;
263 const root = yield* fs.makeTempDirectoryScoped();
264 const prefix = path.join(root, "bunx-tools");
265 const packageRoot = path.join(prefix, "lib/node_modules/t3");
266 const launcher = path.join(packageRoot, "bin/t3.mjs");
267 const executable = path.join(root, ".t3/runtime/versions/1.0.0/t3");
268 yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
269 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
270 yield* fs.makeDirectory(path.join(prefix, "bin"));
271 yield* fs.writeFileString(launcher, "");
272 yield* fs.writeFileString(executable, "");
273 yield* fs.symlink(launcher, path.join(prefix, "bin/t3"));
274 const resolve = resolveServerInstallation.pipe(
275 Effect.provideService(HostProcess.ExecutablePath, executable),
276 Effect.provideService(HostProcess.Arguments, [executable, executable]),
277 Effect.provideService(HostProcess.IsExecutable, true),
278 Effect.provideService(HostProcess.Platform, "linux"),
279 Effect.provideService(HostProcess.Environment, { T3CODE_NPM_LAUNCHER_PATH: launcher }),
280 );
281 for (const [version, expected] of [
282 ["0.0.1", null],
283 [packageJson.version, { kind: "npm-global", prefix }],
284 ] as const) {
285 yield* fs.writeFileString(
286 path.join(packageRoot, "package.json"),
287 `{"name":"t3","version":"${version}","bin":{"t3":"./bin/t3.mjs"}}`,
288 );
289 expect(yield* resolve).toEqual(expected);
290 }
291 }),
292 );
293​
294 it.effect("names the launcher symlink that started a downloaded runtime", () =>
295 Effect.gen(function* () {
296 const fs = yield* FileSystem.FileSystem;
297 const path = yield* Path.Path;
298 const root = yield* fs.realPath(yield* fs.makeTempDirectoryScoped());
299 // The install script's launcher names ~/.t3, which here links to a home elsewhere.
300 const executable = path.join(root, "data/t3/runtime/versions/1.0.0/t3");
301 const launcher = path.join(root, "bin/t3");
302 const copy = path.join(root, "bin/t3-copy");
303 yield* fs.makeDirectory(path.dirname(executable), { recursive: true });
304 yield* fs.makeDirectory(path.dirname(launcher), { recursive: true });
305 yield* fs.writeFileString(executable, "", { mode: 0o755 });
306 yield* fs.writeFileString(copy, "", { mode: 0o755 });
307 yield* fs.symlink(path.join(root, "data/t3"), path.join(root, ".t3"));
308 yield* fs.symlink(path.join(root, ".t3/runtime/versions/1.0.0/t3"), launcher);
309 // Earlier PATH entries hold a directory and a non-executable file named t3.
310 yield* fs.makeDirectory(path.join(root, "shadow-dir/t3"), { recursive: true });
311 yield* fs.makeDirectory(path.join(root, "shadow-file"));
312 yield* fs.writeFileString(path.join(root, "shadow-file/t3"), "");
313 const resolve = (invokedAs: string) =>
314 resolveServerInstallation.pipe(
315 Effect.provideService(HostProcess.ExecutablePath, executable),
316 Effect.provideService(HostProcess.IsExecutable, true),
317 Effect.provideService(HostProcess.InvokedAs, invokedAs),
318 Effect.provideService(HostProcess.WorkingDirectory, root),
319 Effect.provideService(HostProcess.Environment, { PATH: "shadow-dir:shadow-file:bin" }),
320 Effect.provideService(HostProcess.Platform, "linux"),
321 );
322 const baseDir = path.join(root, ".t3");
323​
324 expect(yield* resolve("t3")).toEqual({ kind: "t3-launcher", launcher, baseDir });
325 expect(yield* resolve(launcher)).toEqual({ kind: "t3-launcher", launcher, baseDir });
326 expect(yield* resolve(executable)).toBeNull();
327 expect(yield* resolve(copy)).toBeNull();
328 }),
329 );
330​
331 it.effect("leaves local, standalone, missing and unreadable installs unknown", () =>
332 Effect.gen(function* () {
333 const fs = yield* FileSystem.FileSystem;
334 const path = yield* Path.Path;
335 const root = yield* fs.makeTempDirectoryScoped();
336 for (const relative of [
337 "project/node_modules/t3/dist/bin.mjs",
338 "project/apps/server/dist/bin.mjs",
339 ".t3/runtime/0.0.45/t3",
340 "missing/dist/bin.mjs",
341 ]) {
342 const entry = path.join(root, relative);
343 if (!relative.startsWith("missing")) {
344 yield* fs.makeDirectory(path.dirname(entry), { recursive: true });
345 yield* fs.writeFileString(entry, "");
346 }
347 expect(
348 yield* resolveServerInstallation.pipe(
349 Effect.provideService(HostProcess.Arguments, ["node", entry]),
350 Effect.provideService(HostProcess.IsExecutable, false),
351 ),
352 ).toBeNull();
353 }
354 }),
355 );
356});