| 1 | // @effect-diagnostics nodeBuiltinImport:off - Runs the rendered launcher in a real shell. |
| 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeFS from "node:fs"; |
| 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | |
| 7 | import { afterEach, describe, expect, it } from "vite-plus/test"; |
| 8 | |
| 9 | import { type CliShimTarget, renderCliShim } from "./DesktopCliShim.ts"; |
| 10 | |
| 11 | const directories: Array<string> = []; |
| 12 | afterEach(() => { |
| 13 | for (const directory of directories.splice(0)) { |
| 14 | NodeFS.rmSync(directory, { recursive: true, force: true }); |
| 15 | } |
| 16 | }); |
| 17 | |
| 18 | const tempRoot = () => { |
| 19 | const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-cli-shim-")); |
| 20 | directories.push(root); |
| 21 | return root; |
| 22 | }; |
| 23 | |
| 24 | const 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. */ |
| 30 | const 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 | |
| 36 | const 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. */ |
| 43 | const 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 | */ |
| 52 | const 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 | |
| 74 | describe("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 | }); |