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
29/** Writes the launcher for `target` at `<root>/bin/t3`, with `<root>/home` as its T3 home. */
30const writeShim = (root: string, target: CliShimTarget) => {
31 const shim = NodePath.join(root, "bin", "t3");
32 writeExecutable(shim, renderCliShim({ target, shimPath: shim, t3Home: `${root}/home` }));
33 return shim;
34};
35
36const run = (shim: string, args: ReadonlyArray<string>, env: NodeJS.ProcessEnv = {}) =>
37 NodeChildProcess.spawnSync(shim, args, {
38 encoding: "utf8",
39 env: { PATH: process.env.PATH, ...env },
40 });
41
42/** An app executable that reports its environment and arguments. */
43const REPORTER =
44 '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n';
45
46/**
47 * A stand-in AppImage whose image holds the reporter. `--appimage-mount`
48 * prints the mount and stays up until killed, recording that it was; with
49 * `canMount` false it fails like a host without FUSE. `--appimage-extract`
50 * unpacks into ./squashfs-root.
51 */
52const fakeAppImage = (canMount = true) => {
53 const root = tempRoot();
54 const image = NodePath.join(root, "image");
55 writeExecutable(NodePath.join(image, "t3code"), REPORTER);
56 const appImage = NodePath.join(root, "T3 Code.AppImage");
57 writeExecutable(
58 appImage,
59 [
60 "#!/bin/sh",
61 `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`,
62 '[ "$1" = --appimage-mount ] || exit 9',
63 '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8',
64 canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1',
65 `trap 'touch "${root}/unmounted"; exit 0' TERM`,
66 "while :; do sleep 0.05; done",
67 "",
68 ].join("\n"),
69 );
70 const shim = writeShim(root, { kind: "appimage", appImage, executableName: "t3code" });
71 return { root, image, appImage, shim };
72};
73
74describe("renderCliShim", () => {
75 it("runs the AppImage's bundled CLI with every argument and unmounts after", () => {
76 const { root, image, shim } = fakeAppImage();
77 const result = run(shim, ["browser", "setup", "a b"]);
78 expect(result.status).toBe(3);
79 expect(result.stdout.split("\n")).toEqual([
80 `node=1 cli=${shim} home=${root}/home`,
81 `${image}/resources/app.asar/apps/server/dist/bin.mjs`,
82 "browser",
83 "setup",
84 "a b",
85 "",
86 ]);
87 // The trap kills the mount helper as the launcher exits.
88 NodeChildProcess.spawnSync("sh", [
89 "-c",
90 `i=0; while [ ! -e '${root}/unmounted' ] && [ $i -lt 40 ]; do sleep 0.05; i=$((i+1)); done`,
91 ]);
92 expect(NodeFS.existsSync(NodePath.join(root, "unmounted"))).toBe(true);
93 });
94
95 it("extracts the AppImage where it cannot be mounted", () => {
96 const { shim } = fakeAppImage(false);
97 // Extract-and-run is ignored too, so the runtime never starts the app.
98 const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" });
99 expect(result.status).toBe(3);
100 expect(result.stdout).toMatch(
101 /squashfs-root\/resources\/app\.asar\/apps\/server\/dist\/bin\.mjs/,
102 );
103 });
104
105 it("keeps the install's T3 home under sudo, unless one is set", () => {
106 const root = tempRoot();
107 writeExecutable(NodePath.join(root, "app"), REPORTER);
108 const shim = writeShim(root, {
109 kind: "direct",
110 executable: NodePath.join(root, "app"),
111 entry: "/bin.mjs",
112 });
113 // sudo clears the environment, so the launcher supplies its own.
114 expect(run(shim, []).stdout).toContain(`home=${root}/home`);
115 expect(run(shim, [], { T3CODE_HOME: "/elsewhere" }).stdout).toContain("home=/elsewhere");
116 // Run by a relative path, it still names itself absolutely.
117 const relative = NodeChildProcess.spawnSync("./bin/t3", [], { cwd: root, encoding: "utf8" });
118 expect(relative.stdout).toContain(`cli=${shim}`);
119 });
120
121 it("runs an installed app's server directly", () => {
122 const root = tempRoot();
123 const executable = NodePath.join(root, "opt", "T3 Code", "t3code");
124 writeExecutable(executable, REPORTER);
125 const shim = writeShim(root, {
126 kind: "direct",
127 executable,
128 entry: "/opt/T3's/app.asar/bin.mjs",
129 });
130 expect(run(shim, ["browser", "setup"]).stdout.split("\n").slice(1)).toEqual([
131 "/opt/T3's/app.asar/bin.mjs",
132 "browser",
133 "setup",
134 "",
135 ]);
136 });
137
138 it("tells a person whose app moved to reopen it", () => {
139 const root = tempRoot();
140 const shim = writeShim(root, {
141 kind: "direct",
142 executable: NodePath.join(root, "Gone.app/Contents/MacOS/T3 Code"),
143 entry: "/bin.mjs",
144 });
145 const result = run(shim, ["--version"]);
146 expect(result.status).toBe(127);
147 expect(result.stderr).toContain("Open the app once to update this command.");
148 });
149
150 it("writes a Windows launcher that keeps cmd from reinterpreting paths", () => {
151 const executable = "C:\\Apps\\R&whoami&X 100%\\!CHANNEL!\\T3 Code.exe";
152 const script = renderCliShim({
153 target: { kind: "windows", executable, entry: "C:\\Apps\\server.asar\\bin.mjs" },
154 shimPath: "C:\\Users\\José\\.t3\\bin\\t3.cmd",
155 t3Home: "C:\\Users\\José\\.t3",
156 });
157 const lines = script.split("\r\n");
158 expect(lines).toContain("setlocal EnableExtensions DisableDelayedExpansion");
159 expect(lines).toContain("chcp 65001 >nul");
160 expect(lines).toContain(
161 'if exist "C:\\Apps\\R&whoami&X 100%%\\!CHANNEL!\\T3 Code.exe" goto run',
162 );
163 // Paths only ever appear quoted or inside `set "..."`, never bare where `&` would split them.
164 const bare = lines.filter(
165 (line) => line.includes("whoami") && !/"[^"]*whoami[^"]*"/.test(line),
166 );
167 expect(bare).toEqual([]);
168 expect(lines).toContain('set "T3CODE_CLI_PATH=C:\\Users\\José\\.t3\\bin\\t3.cmd"');
169 });
170
171 it("switches the Windows console to UTF-8 only when a path needs it", () => {
172 const ascii = renderCliShim({
173 target: { kind: "windows", executable: "C:\\T3\\T3 Code.exe", entry: "C:\\T3\\bin.mjs" },
174 shimPath: "C:\\Users\\me\\.t3\\bin\\t3.cmd",
175 t3Home: "C:\\Users\\me\\.t3",
176 });
177 // A Ctrl-C that ends the batch would leave the console switched.
178 expect(ascii).not.toContain("chcp");
179 expect(ascii.split("\r\n")).toContain("exit /b %t3_exit%");
180 });
181});