Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import { afterEach, describe, expect, it } from "vite-plus/test";
8
9import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts";
10
11const directories: Array<string> = [];
12afterEach(() => {
13 for (const directory of directories.splice(0)) {
14 NodeFS.rmSync(directory, { recursive: true, force: true });
15 }
16});
17
18const tempRoot = () => {
19 const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-"));
20 directories.push(root);
21 return root;
22};
23
24const writeExecutable = (path: string, content: string) => {
25 NodeFS.mkdirSync(NodePath.dirname(path), { recursive: true });
26 NodeFS.writeFileSync(path, content, { mode: 0o755 });
27};
28
29const writeShim = (root: string, target: CliShimTarget) => {
30 const shim = NodePath.join(root, "bin", "t3");
31 writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` }));
32 return shim;
33};
34
35const run = (shim: string, args: ReadonlyArray<string>, env: NodeJS.ProcessEnv = {}) =>
36 NodeChildProcess.spawnSync(shim, args, {
37 encoding: "utf8",
38 env: { PATH: process.env.PATH, ...env },
39 });
40
41const REPORTER =
42 '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME appimage=$APPIMAGE"\nprintf "%s\\n" "$@"\nexit 3\n';
43
44const fakeAppImage = (canMount = true, app = REPORTER) => {
45 const root = tempRoot();
46 const image = NodePath.join(root, "image");
47 writeExecutable(NodePath.join(image, "t3code"), app);
48 const appImage = NodePath.join(root, "T3 Code.AppImage");
49 writeExecutable(
50 appImage,
51 [
52 "#!/bin/sh",
53 `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`,
54 '[ "$1" = --appimage-mount ] || exit 9',
55 '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8',
56 `echo $$ >'${root}/mounter.pid'`,
57 canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1',
58 // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open.
59 "trap 'echo unmounted >&3 2>/dev/null; exit 0' TERM HUP INT",
60 "trap 'echo mounted >&3 2>/dev/null' WINCH",
61 "while :; do sleep 0.05; done",
62 "",
63 ].join("\n"),
64 );
65 const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" });
66 return { root, image, appImage, shim };
67};
68
69// Stays up after a signal until the test lets it finish, like a server shutting down.
70const SLOW_SERVER = [
71 "#!/bin/sh",
72 "trap 'echo stopping >&3; read go; echo stopped >&3; exit 0' TERM HUP",
73 "echo ready",
74 "while :; do sleep 0.05; done",
75 "",
76].join("\n");
77
78// The launcher mounts only under setsid, which stock macOS and Windows lack; there it extracts instead.
79const hasSetsid = NodeChildProcess.spawnSync("setsid", ["true"]).status === 0;
80
81const lines = (stream: NodeJS.ReadableStream) => {
82 const seen: Array<string> = [];
83 const waiters: Array<() => void> = [];
84 let buffer = "";
85 stream.setEncoding("utf8");
86 stream.on("data", (chunk: string) => {
87 buffer += chunk;
88 const parts = buffer.split("\n");
89 buffer = parts.pop()!;
90 seen.push(...parts);
91 for (const wake of waiters.splice(0)) wake();
92 });
93 const until = async (match: (line: string) => boolean) => {
94 for (;;) {
95 const line = seen.find(match);
96 if (line !== undefined) return line;
97 await new Promise<void>((resolve) => waiters.push(resolve));
98 }
99 };
100 return { seen, until };
101};
102
103describe("renderCliShim", () => {
104 it.skipIf(!hasSetsid).each(["SIGTERM", "SIGHUP"] as const)(
105 "keeps the AppImage mounted until the server exits after %s to the process group",
106 async (signal) => {
107 const { root, shim } = fakeAppImage(true, SLOW_SERVER);
108 const child = NodeChildProcess.spawn(shim, [], {
109 detached: true,
110 env: { PATH: process.env.PATH },
111 stdio: ["pipe", "pipe", "ignore", "pipe"],
112 });
113 let mounter: number | undefined;
114 try {
115 const stdout = lines(child.stdout!);
116 const events = lines(child.stdio[3] as NodeJS.ReadableStream);
117 const exited = new Promise((resolve) => child.on("close", resolve));
118 await stdout.until((line) => line === "ready");
119 mounter = Number(NodeFS.readFileSync(NodePath.join(root, "mounter.pid"), "utf8"));
120
121 // Ctrl-C in a terminal, a closed SSH session, or a service manager signals the whole group.
122 process.kill(-child.pid!, signal);
123 // The shell runs pending traps in ascending signal number, and WINCH outnumbers TERM and
124 // HUP, so a mounter that also got the group signal unmounts before it answers this probe.
125 process.kill(mounter, "SIGWINCH");
126 const mount = await events.until((line) => line === "mounted" || line === "unmounted");
127 expect(mount).toBe("mounted");
128
129 await events.until((line) => line === "stopping");
130 child.stdin!.end("go\n");
131 await exited;
132 expect(events.seen.slice(-2)).toEqual(["stopped", "unmounted"]);
133 } finally {
134 for (const pid of [-child.pid!, mounter]) {
135 if (pid === undefined) continue;
136 try {
137 process.kill(pid, "SIGKILL");
138 } catch {}
139 }
140 }
141 },
142 );
143
144 it("extracts the AppImage where it cannot be mounted", () => {
145 const { shim } = fakeAppImage(false);
146 const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" });
147 expect(result.status).toBe(3);
148 expect(result.stdout).toMatch(
149 /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/,
150 );
151 });
152
153 // The server skips its compile cache only under APPIMAGE; each mount path is new.
154 it("tells the CLI it runs from the mounted AppImage", () => {
155 const { appImage, shim } = fakeAppImage();
156 const result = run(shim, ["--version"]);
157 expect(result.status).toBe(3);
158 expect(result.stdout).toContain(`appimage=${appImage}`);
159 });
160
161 it("keeps the install's T3 home under sudo, unless one is set", () => {
162 const root = tempRoot();
163 writeExecutable(NodePath.join(root, "app"), REPORTER);
164 const shim = writeShim(root, {
165 kind: "direct",
166 executable: NodePath.join(root, "app"),
167 entry: "/bin.mjs",
168 });
169 expect(run(shim, []).stdout).toContain(`home=${root}/home`);
170 expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere");
171 const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" });
172 expect(relative.stdout).toContain(`cli=${shim}`);
173 });
174
175 it("runs an installed app's server directly", () => {
176 const root = tempRoot();
177 const executable = NodePath.join(root, "opt", "T3 Code", "t3code");
178 writeExecutable(executable, REPORTER);
179 const shim = writeShim(root, {
180 kind: "direct",
181 executable,
182 entry: "/opt/T3's/app.asar/bin.mjs",
183 });
184 expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([
185 "/opt/T3's/app.asar/bin.mjs",
186 "browser",
187 "setup",
188 "",
189 ]);
190 });
191
192 it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => {
193 const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe";
194 const script = renderCliShim({
195 target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" },
196 shimPath: "C:\\Users\\José\\.t3\\bin\\t3.cmd",
197 t3Home: "C:\\Users\\José\\.t3",
198 });
199 const lines = script.split("\r\n");
200 expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion");
201 expect(lines).toContain("chcp 65001 >nul");
202 expect(lines).toContain(
203 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run',
204 );
205 const bare = lines.filter(
206 (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line),
207 );
208 expect(bare).toEqual([]);
209 expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"');
210 });
211});