1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { afterEach, beforeEach, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Option from "effect/Option";
6import * as Path from "effect/Path";
7import * as Sink from "effect/Sink";
8import * as Stream from "effect/Stream";
9import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
10
11import * as DesktopCliCommand from "./DesktopCliCommand.ts";
12import * as DesktopCliShim from "./DesktopCliShim.ts";
13import * as DesktopEnvironment from "./DesktopEnvironment.ts";
14
15const environmentFor = (
16 path: Path.Path,
17 input: { home: string; baseDir: string; platform?: NodeJS.Platform; isPackaged?: boolean },
18) =>
19 DesktopEnvironment.DesktopEnvironment.of({
20 path,
21 platform: input.platform ?? "linux",
22 isPackaged: input.isPackaged ?? true,
23 homeDirectory: input.home,
24 baseDir: input.baseDir,
25 stateDir: path.join(input.baseDir, "userdata"),
26 serverRoot: "/opt/T3 Code/resources/app.asar",
27 appImagePath: Option.none(),
28 } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]);
29
30/** The service for a packaged app; the launcher is written on Install. */
31const commandIn = (
32 input: { home: string; baseDir?: string; platform?: NodeJS.Platform; isPackaged?: boolean },
33 spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"],
34) =>
35 Effect.gen(function* () {
36 const path = yield* Path.Path;
37 const fs = yield* FileSystem.FileSystem;
38 const baseDir = input.baseDir ?? path.join(input.home, ".t3");
39 yield* fs.makeDirectory(path.join(baseDir, "userdata"), { recursive: true });
40 const make = DesktopCliCommand.make.pipe(
41 Effect.provideService(
42 DesktopEnvironment.DesktopEnvironment,
43 environmentFor(path, { ...input, baseDir }),
44 ),
45 );
46 return yield* spawner
47 ? make.pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner))
48 : make;
49 });
50
51/** A PowerShell that keeps the user's PATH in memory and can be told to fail reads. */
52const fakePowerShell = (initial: string) => {
53 const registry = { path: initial, failReads: false, writes: 0 };
54 const spawner = ChildProcessSpawner.make((command) =>
55 Effect.sync(() => {
56 const { options } = command as unknown as {
57 readonly options: { readonly env?: Record<string, string> };
58 };
59 const env = options.env ?? {};
60 let exitCode = 0;
61 let stdout = "";
62 if (env.T3_SET === "1") {
63 registry.path = env.T3_PATH ?? "";
64 registry.writes += 1;
65 } else if (registry.failReads) {
66 exitCode = 1;
67 } else {
68 stdout = Buffer.from(registry.path, "utf8").toString("base64");
69 }
70 return ChildProcessSpawner.makeHandle({
71 pid: ChildProcessSpawner.ProcessId(1),
72 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
73 isRunning: Effect.succeed(false),
74 kill: () => Effect.void,
75 unref: Effect.succeed(Effect.void),
76 stdin: Sink.drain,
77 stdout: Stream.make(new TextEncoder().encode(stdout)),
78 stderr: Stream.empty,
79 all: Stream.empty,
80 getInputFd: () => Sink.drain,
81 getOutputFd: () => Stream.empty,
82 });
83 }),
84 );
85 return { registry, spawner };
86};
87
88// The machine's own PATH may already hold a t3; each test sets the PATH it means.
89const machinePath = process.env.PATH;
90beforeEach(() => {
91 process.env.PATH = "";
92});
93afterEach(() => {
94 process.env.PATH = machinePath;
95});
96
97it.layer(NodeServices.layer)("DesktopCliCommand", (it) => {
98 it.effect("links the launcher onto PATH and removes only that link", () =>
99 Effect.gen(function* () {
100 const fs = yield* FileSystem.FileSystem;
101 const path = yield* Path.Path;
102 const home = yield* fs.makeTempDirectoryScoped();
103 const command = yield* commandIn({ home });
104 const link = path.join(home, ".local", "bin", "t3");
105
106 expect(yield* command.state).toEqual({ supported: true, installedPath: null, onPath: false });
107 // Install writes the launcher itself, even when no local backend ever did.
108 const installed = yield* command.install;
109 expect(installed.installedPath).toBe(link);
110 expect(yield* fs.readLink(link)).toBe(path.join(home, ".t3", "bin", "t3"));
111 expect((yield* command.install).installedPath).toBe(link);
112
113 expect((yield* command.uninstall).installedPath).toBeNull();
114 expect(yield* fs.exists(link)).toBe(false);
115 // The launcher itself stays for setup commands.
116 expect(yield* fs.exists(path.join(home, ".t3", "bin", "t3"))).toBe(true);
117 }).pipe(Effect.scoped),
118 );
119
120 it.effect("never replaces or removes a t3 it did not create", () =>
121 Effect.gen(function* () {
122 const fs = yield* FileSystem.FileSystem;
123 const path = yield* Path.Path;
124 const home = yield* fs.makeTempDirectoryScoped();
125 const command = yield* commandIn({ home });
126 const theirs = path.join(home, ".local", "bin", "t3");
127 yield* fs.makeDirectory(path.dirname(theirs), { recursive: true });
128 yield* fs.writeFileString(theirs, "npm's t3\n");
129 // Even a broken link in the next folder is someone else's.
130 yield* fs.makeDirectory(path.join(home, "bin"), { recursive: true });
131 yield* fs.symlink(path.join(home, "gone"), path.join(home, "bin", "t3"));
132
133 const error = yield* Effect.flip(command.install);
134 expect(error.message).toContain("Another t3 command is already installed");
135 yield* command.uninstall;
136 expect(yield* fs.readFileString(theirs)).toBe("npm's t3\n");
137 expect(yield* fs.readLink(path.join(home, "bin", "t3"))).toBe(path.join(home, "gone"));
138 }).pipe(Effect.scoped),
139 );
140
141 it.effect("finds and removes a link left by a previous T3 home", () =>
142 Effect.gen(function* () {
143 const fs = yield* FileSystem.FileSystem;
144 const path = yield* Path.Path;
145 const home = yield* fs.makeTempDirectoryScoped();
146 const before = yield* commandIn({ home, baseDir: path.join(home, "old-t3") });
147 const link = (yield* before.install).installedPath;
148
149 const after = yield* commandIn({ home, baseDir: path.join(home, "new-t3") });
150 expect((yield* after.state).installedPath).toBe(link);
151 // Installing again points the link at this home's launcher.
152 expect((yield* after.install).installedPath).toBe(link);
153 expect(yield* fs.readLink(link!)).toBe(path.join(home, "new-t3", "bin", "t3"));
154 yield* after.uninstall;
155 expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
156 expect(yield* fs.exists(path.join(home, "bin", "t3"))).toBe(false);
157 }).pipe(Effect.scoped),
158 );
159
160 it.effect("does not read a large binary another t3 links to", () =>
161 Effect.gen(function* () {
162 const fs = yield* FileSystem.FileSystem;
163 const path = yield* Path.Path;
164 const home = yield* fs.makeTempDirectoryScoped();
165 // A big executable that happens to contain the marker text is still not ours.
166 const binary = path.join(home, "native-t3");
167 yield* fs.writeFileString(binary, `${"\0".repeat(64 * 1024)}${DesktopCliShim.MARKER}`);
168 yield* fs.makeDirectory(path.join(home, ".local", "bin"), { recursive: true });
169 yield* fs.symlink(binary, path.join(home, ".local", "bin", "t3"));
170 const command = yield* commandIn({ home });
171 expect((yield* command.state).installedPath).toBeNull();
172 yield* command.uninstall;
173 expect(yield* fs.readLink(path.join(home, ".local", "bin", "t3"))).toBe(binary);
174 }).pipe(Effect.scoped),
175 );
176
177 it.effect("refuses to install behind another t3 that runs first", () =>
178 Effect.gen(function* () {
179 const fs = yield* FileSystem.FileSystem;
180 const path = yield* Path.Path;
181 const home = yield* fs.makeTempDirectoryScoped();
182 const shadow = path.join(home, "shadow");
183 yield* fs.makeDirectory(shadow);
184 yield* fs.writeFileString(path.join(shadow, "t3"), "#!/bin/sh\n", { mode: 0o755 });
185 process.env.PATH = [shadow, path.join(home, ".local", "bin")].join(":");
186
187 const command = yield* commandIn({ home });
188 const theirs = path.join(shadow, "t3");
189 expect((yield* command.state).shadowedBy).toBe(theirs);
190 // A link behind it would never run, so nothing is created.
191 const error = yield* Effect.flip(command.install);
192 expect(error.message).toContain(theirs);
193 expect(yield* fs.exists(path.join(home, ".local", "bin", "t3"))).toBe(false);
194
195 yield* fs.remove(theirs);
196 const installed = yield* command.install;
197 expect(installed).toMatchObject({
198 onPath: true,
199 installedPath: path.join(home, ".local", "bin", "t3"),
200 });
201 expect(installed.shadowedBy).toBeUndefined();
202 }).pipe(Effect.scoped),
203 );
204
205 it.effect("leaves the Windows PATH alone when it cannot be read", () =>
206 Effect.gen(function* () {
207 const fs = yield* FileSystem.FileSystem;
208 const home = yield* fs.makeTempDirectoryScoped();
209 const userPath = "C:\\Users\\Me\\npm;%USERPROFILE%\\tools";
210 const { registry, spawner } = fakePowerShell(userPath);
211 const command = yield* commandIn({ home, platform: "win32" }, spawner);
212
213 registry.failReads = true;
214 const error = yield* Effect.flip(command.install);
215 expect(error.message).toContain("left unchanged");
216 expect(registry).toMatchObject({ path: userPath, writes: 0 });
217
218 registry.failReads = false;
219 const launcherDir = DesktopCliShim.launcherPath(
220 environmentFor(yield* Path.Path, { home, baseDir: `${home}/.t3`, platform: "win32" }),
221 ).replace(/[\\/]t3\.cmd$/, "");
222 yield* command.install;
223 expect(registry.path).toBe(`${userPath};${launcherDir}`);
224 yield* command.uninstall;
225 expect(registry.path).toBe(userPath);
226 }).pipe(Effect.scoped),
227 );
228
229 it.effect("does not claim or remove a Windows PATH entry the user added", () =>
230 Effect.gen(function* () {
231 const fs = yield* FileSystem.FileSystem;
232 const path = yield* Path.Path;
233 const home = yield* fs.makeTempDirectoryScoped();
234 const launcherDir = path.dirname(
235 DesktopCliShim.launcherPath(
236 environmentFor(path, { home, baseDir: path.join(home, ".t3"), platform: "win32" }),
237 ),
238 );
239 const userPath = `C:\\Tools;${launcherDir}`;
240 const { registry, spawner } = fakePowerShell(userPath);
241 const command = yield* commandIn({ home, platform: "win32" }, spawner);
242
243 expect((yield* command.state).installedPath).toBeNull();
244 yield* command.uninstall;
245 expect(registry.path).toBe(userPath);
246 }).pipe(Effect.scoped),
247 );
248
249 it.effect("offers nothing for a development build", () =>
250 Effect.gen(function* () {
251 const fs = yield* FileSystem.FileSystem;
252 const command = yield* commandIn({
253 home: yield* fs.makeTempDirectoryScoped(),
254 isPackaged: false,
255 });
256 expect((yield* command.state).supported).toBe(false);
257 }).pipe(Effect.scoped),
258 );
259});