Skip to content
Source
Sign inSign up

install.test.ts

520 lines22.5 KB

1// @effect-diagnostics nodeBuiltinImport:off - Drives the real shell installer against a local GitHub fixture.
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeCrypto from "node:crypto";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeHttp from "node:http";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import { describe, expect, it } from "vite-plus/test";
10​
11const nightlies = Array.from({ length: 100 }, (_, index) => ({
12 tag_name: `v1.2.4-nightly.20261020.${200 - index}`,
13}));
14​
15describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer home", () => {
16 it("installs a relative home from a directory ending in a newline", async () => {
17 const temporaryRoot = await NodeFSP.realpath(
18 await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-home-")),
19 );
20 const root = NodePath.join(temporaryRoot, "cwd\n");
21 const homeName = "-custom home\\home\n";
22 const home = NodePath.join(root, homeName);
23 const version = "1.2.3";
24 const target = NodePath.join(home, "runtime/versions", version);
25 const launcher = NodePath.join(root, "bin/t3");
26 try {
27 await NodeFSP.mkdir(target, { recursive: true });
28 await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`);
29 await NodeFSP.writeFile(NodePath.join(target, "t3"), "#!/bin/sh\necho 't3 v1.2.3'\n", {
30 mode: 0o755,
31 });
32 const { status, stdout, stderr } = NodeChildProcess.spawnSync(
33 "sh",
34 [NodePath.resolve(import.meta.dirname, "install.sh")],
35 {
36 cwd: root,
37 env: {
38 ...process.env,
39 HOME: root,
40 T3CODE_HOME: homeName,
41 T3CODE_VERSION: version,
42 T3CODE_INSTALL_BIN_DIR: NodePath.dirname(launcher),
43 T3CODE_RELEASE_BASE_URL: "http://127.0.0.1:1",
44 },
45 encoding: "utf8",
46 },
47 );
48 expect(status, stdout + stderr).toBe(0);
49 expect(stderr).toContain(`Installed T3 Code ${version}`);
50 expect(await NodeFSP.readlink(launcher)).toBe(NodePath.join(target, "t3"));
51 expect(
52 NodeChildProcess.execFileSync(launcher, ["--version"], {
53 cwd: NodeOS.tmpdir(),
54 encoding: "utf8",
55 }).trim(),
56 ).toBe("t3 v1.2.3");
57 } finally {
58 await NodeFSP.rm(temporaryRoot, { recursive: true, force: true });
59 }
60 });
61});
62​
63// regression: #22490
64describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer channel lookup", () => {
65 it.each([
66 // regression: #32897
67 ["stable", "1.2.3", 1],
68 ["preview", "1.2.4-preview.20261019.50", 0],
69 ])("finds %s %s behind nightlies and %i HTTP 504s", async (channel, version, failures) => {
70 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-index-"));
71 const target = NodePath.join(root, "home/runtime/versions", version);
72 const server = NodeHttp.createServer((request, response) => {
73 if (failures-- > 0) return response.writeHead(504).end();
74 const url = new URL(request.url ?? "/", "http://fixture");
75 const body =
76 url.pathname === "/pingdotgg/t3code/releases/latest" &&
77 request.headers.accept === "application/json"
78 ? { tag_name: "v1.2.3" }
79 : url.pathname === "/repos/pingdotgg/t3code/releases"
80 ? { "1": nightlies, "2": [{ tag_name: `v${version}` }, { tag_name: "v1.2.3" }] }[
81 url.searchParams.get("page") ?? ""
82 ]
83 : undefined;
84 if (body === undefined) response.statusCode = 404;
85 // The API pretty-prints and the installer reads it line by line.
86 response.end(JSON.stringify(body ?? [], null, 2));
87 });
88 try {
89 // An existing completed install isolates release resolution from archive downloads.
90 await NodeFSP.mkdir(target, { recursive: true });
91 await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), `${version}\n`);
92 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
93 const address = server.address();
94 if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
95 const origin = `http://127.0.0.1:${address.port}`;
96 const source = await NodeFSP.readFile(
97 NodePath.resolve(import.meta.dirname, "install.sh"),
98 "utf8",
99 );
100 const installer = NodePath.join(root, "install.sh");
101 await NodeFSP.writeFile(installer, source.replace(/https:\/\/(api\.)?github\.com/g, origin));
102 const { code, output } = await new Promise<{ code: number | null; output: string }>(
103 (resolve, reject) => {
104 const child = NodeChildProcess.spawn("sh", [installer], {
105 env: {
106 ...process.env,
107 T3CODE_VERSION: "",
108 T3CODE_CHANNEL: channel,
109 T3CODE_HOME: NodePath.join(root, "home"),
110 T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"),
111 // A wrong version must fail here instead of downloading a real release.
112 T3CODE_RELEASE_BASE_URL: origin,
113 },
114 stdio: ["ignore", "pipe", "pipe"],
115 });
116 let output = "";
117 child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
118 child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
119 child.on("error", reject);
120 child.on("close", (code) => resolve({ code, output }));
121 },
122 );
123 expect(code, output).toBe(0);
124 expect(output).toContain(`Installed T3 Code ${version}`);
125 } finally {
126 server.closeAllConnections();
127 await new Promise<void>((resolve) => server.close(() => resolve()));
128 await NodeFSP.rm(root, { recursive: true, force: true });
129 }
130 });
131});
132​
133// regression: #35834
134describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")(
135 "installer archive download",
136 () => {
137 it("names the archive when it returns 404", async () => {
138 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-archive-"));
139 const arch = HostProcess.Architecture.defaultValue() === "arm64" ? "arm64" : "x64";
140 const archive = `t3-1.2.3-linux-${arch}.tar.gz`;
141 const server = NodeHttp.createServer((request, response) => {
142 if (request.url === "/v1.2.3/SHA256SUMS")
143 return response.end(`${"0".repeat(64)} ${archive}\n`);
144 response.writeHead(404).end();
145 });
146 try {
147 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
148 const address = server.address();
149 if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
150 const { code, output } = await new Promise<{ code: number | null; output: string }>(
151 (resolve, reject) => {
152 const child = NodeChildProcess.spawn(
153 "sh",
154 [NodePath.resolve(import.meta.dirname, "install.sh")],
155 {
156 env: {
157 ...process.env,
158 T3CODE_CHANNEL: "",
159 T3CODE_VERSION: "1.2.3",
160 T3CODE_HOME: NodePath.join(root, "home"),
161 T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"),
162 T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`,
163 },
164 stdio: ["ignore", "pipe", "pipe"],
165 },
166 );
167 let output = "";
168 child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
169 child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
170 child.on("error", reject);
171 child.on("close", (code) => resolve({ code, output }));
172 },
173 );
174 expect(code, output).not.toBe(0);
175 expect(output).toMatch(new RegExp(`t3 install: .*${archive.replaceAll(".", "\\.")}`));
176 } finally {
177 server.closeAllConnections();
178 await new Promise<void>((resolve) => server.close(() => resolve()));
179 await NodeFSP.rm(root, { recursive: true, force: true });
180 }
181 });
182 },
183);
184​
185/** Runs an installer for a version that is already unpacked, so only the t3 command step runs. */
186async function installOver(input: {
187 readonly script: "install.sh" | "install.ps1";
188 readonly home: string;
189 readonly binDir: string;
190 readonly env?: NodeJS.ProcessEnv;
191 readonly cwd?: string;
192}) {
193 const target = NodePath.join(input.home, "runtime", "versions", "1.2.3");
194 await NodeFSP.mkdir(target, { recursive: true });
195 await NodeFSP.writeFile(NodePath.join(target, ".install-complete"), "1.2.3\n");
196 const installer = NodePath.resolve(import.meta.dirname, input.script);
197 const [command, args] =
198 input.script === "install.sh"
199 ? ["sh", [installer]]
200 : ["powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installer]];
201 const result = NodeChildProcess.spawnSync(command, args, {
202 encoding: "utf8",
203 ...(input.cwd === undefined ? {} : { cwd: input.cwd }),
204 env: {
205 ...process.env,
206 T3CODE_CHANNEL: "",
207 T3CODE_VERSION: "1.2.3",
208 T3CODE_HOME: input.home,
209 T3CODE_INSTALL_BIN_DIR: input.binDir,
210 T3CODE_RELEASE_BASE_URL: "http://127.0.0.1:9",
211 ...input.env,
212 },
213 });
214 return { code: result.status, output: `${result.stdout}${result.stderr}`, target };
216​
217describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("install.sh t3 command", () => {
218 it("replaces only this home's own links and prints a PATH command", async () => {
219 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-link-"));
220 try {
221 const home = NodePath.join(root, "home");
222 const binDir = NodePath.join(root, "bin");
223 const link = NodePath.join(binDir, "t3");
224 await NodeFSP.mkdir(binDir, { recursive: true });
225​
226 await NodeFSP.writeFile(link, "#!/bin/sh\necho mine\n");
227 const foreign = await installOver({ script: "install.sh", home, binDir });
228 expect(foreign.code).not.toBe(0);
229 expect(foreign.output).toContain(`another t3 is already at ${link}`);
230 expect(await NodeFSP.readFile(link, "utf8")).toBe("#!/bin/sh\necho mine\n");
231 // Not even from inside the versions folder, where `.` would resolve into it.
232 const fromVersions = await installOver({
233 script: "install.sh",
234 home,
235 binDir,
236 cwd: foreign.target,
237 });
238 expect(fromVersions.code).not.toBe(0);
239 expect(await NodeFSP.readFile(link, "utf8")).toBe("#!/bin/sh\necho mine\n");
240​
241 // A link that only looks like it points into versions (join() would normalize the ..).
242 await NodeFSP.rm(link);
243 await NodeFSP.mkdir(NodePath.join(home, "other"), { recursive: true });
244 await NodeFSP.writeFile(NodePath.join(home, "other/t3"), "#!/bin/sh\n");
245 await NodeFSP.symlink(`${home}/runtime/versions/../../other/t3`, link);
246 const climbing = await installOver({ script: "install.sh", home, binDir });
247 expect(climbing.code).not.toBe(0);
248 expect(await NodeFSP.readlink(link)).toBe(`${home}/runtime/versions/../../other/t3`);
249​
250 // Only `<version>/t3` counts, not another program inside a version.
251 await NodeFSP.rm(link);
252 await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/helper"), link);
253 const sibling = await installOver({ script: "install.sh", home, binDir });
254 expect(sibling.code).not.toBe(0);
255 expect(await NodeFSP.readlink(link)).toBe(
256 NodePath.join(home, "runtime/versions/1.0.0/helper"),
257 );
258​
259 // An upgrade over an older version's link, written through a symlinked T3 home.
260 await NodeFSP.rm(link);
261 await NodeFSP.symlink(NodePath.join(home, "runtime/versions/1.0.0/t3"), link);
262 await NodeFSP.symlink(home, NodePath.join(root, "alias"));
263 const upgrade = await installOver({
264 script: "install.sh",
265 home: NodePath.join(root, "alias"),
266 binDir,
267 env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
268 });
269 expect(upgrade.code, upgrade.output).toBe(0);
270 expect(await NodeFSP.readlink(link)).toBe(
271 NodePath.join(root, "alias/runtime/versions/1.2.3/t3"),
272 );
273 expect(upgrade.output).toContain(
274 `printf '\\n%s\\n' 'export PATH="$PATH:$HOME/bin"' >> ~/.zshrc`,
275 );
276​
277 // Containers and CI often have no SHELL; `set -u` must not stop the installer there.
278 const noShell = await installOver({
279 script: "install.sh",
280 home,
281 binDir,
282 env: { HOME: root, SHELL: undefined, PATH: "/usr/bin:/bin" },
283 });
284 expect(noShell.code, noShell.output).toBe(0);
285 expect(noShell.output).toContain(`Add ${binDir} to your PATH`);
286​
287 // Its folder on PATH with a trailing slash is still this t3, not another one.
288 const slashed = await installOver({
289 script: "install.sh",
290 home,
291 binDir,
292 env: { HOME: root, PATH: `${binDir}/:/usr/bin:/bin` },
293 });
294 expect(slashed.code, slashed.output).toBe(0);
295 expect(slashed.output).toContain("Run t3 to use T3 Code here");
296​
297 // A relative folder is made absolute, so the PATH command never follows the working directory.
298 const relative = await installOver({
299 script: "install.sh",
300 home,
301 binDir: "bin",
302 cwd: root,
303 env: { HOME: root, SHELL: "/bin/zsh", PATH: "/usr/bin:/bin" },
304 });
305 expect(relative.code, relative.output).toBe(0);
306 expect(relative.output).toContain(`'export PATH="$PATH:$HOME/bin"'`);
307​
308 await NodeFSP.rm(link);
309 await NodeFSP.symlink(NodePath.join(home, "bin/t3"), link);
310 const overDesktop = await installOver({
311 script: "install.sh",
312 home: `${home}//`,
313 binDir: `${binDir}/`,
314 });
315 expect(overDesktop.code, overDesktop.output).toBe(0);
316 expect(await NodeFSP.readlink(link)).toBe(NodePath.join(home, "runtime/versions/1.2.3/t3"));
317 } finally {
318 await NodeFSP.rm(root, { recursive: true, force: true });
319 }
320 });
321});
322​
323describe.skipIf(HostProcess.Platform.defaultValue() !== "win32")("install.ps1 t3 command", () => {
324 it("replaces only its own t3.cmd, also in non-ASCII and bracketed paths", async () => {
325 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-ps1-"));
326 try {
327 // Windows PowerShell 5.1 reads files as ANSI by default and expands wildcards in paths.
328 const home = NodePath.join(root, "José", ".t3");
329 const binDir = NodePath.join(root, "bin[t3]");
330 const shim = NodePath.join(binDir, "t3.cmd");
331 await NodeFSP.mkdir(binDir, { recursive: true });
332​
333 const desktopShim = '@echo off\r\n"C:\\Program Files\\T3 Code\\T3 Code.exe" "x" %*\r\n';
334 await NodeFSP.writeFile(shim, desktopShim);
335 const foreign = await installOver({ script: "install.ps1", home, binDir });
336 expect(foreign.code).not.toBe(0);
337 expect(foreign.output).toContain("another t3 is already at");
338 expect(await NodeFSP.readFile(shim, "utf8")).toBe(desktopShim);
339​
340 // A hand-edited wrapper may set its own environment; replacing it would drop that.
341 const edited = `@echo off\r\nset "T3CODE_HOME=D:\\other"\r\n"${NodePath.join(home, "runtime", "versions", "1.0.0", "t3.exe")}" %*`;
342 await NodeFSP.writeFile(shim, edited, "utf8");
343 const wrapper = await installOver({ script: "install.ps1", home, binDir });
344 expect(wrapper.code).not.toBe(0);
345 expect(await NodeFSP.readFile(shim, "utf8")).toBe(edited);
346​
347 const climbing = `@echo off\r\n"${NodePath.join(home, "runtime", "versions")}\\..\\other\\t3.exe" %*`;
348 await NodeFSP.writeFile(shim, climbing, "utf8");
349 const outside = await installOver({ script: "install.ps1", home, binDir });
350 expect(outside.code).not.toBe(0);
351 expect(await NodeFSP.readFile(shim, "utf8")).toBe(climbing);
352​
353 const older = NodePath.join(home, "runtime", "versions", "1.0.0", "t3.exe");
354 await NodeFSP.writeFile(shim, `@echo off\r\n"${older}" %*`, "utf8");
355 const upgrade = await installOver({ script: "install.ps1", home, binDir });
356 expect(upgrade.code, upgrade.output).toBe(0);
357 expect(await NodeFSP.readFile(shim, "utf8")).toContain(
358 NodePath.join(upgrade.target, "t3.exe"),
359 );
360​
361 const again = await installOver({ script: "install.ps1", home, binDir });
362 expect(again.code, again.output).toBe(0);
363​
364 // Another t3 first on PATH still runs, so the installer names this one in full.
365 const otherBin = NodePath.join(root, "other");
366 await NodeFSP.mkdir(otherBin);
367 await NodeFSP.writeFile(NodePath.join(otherBin, "t3.cmd"), "@echo off\r\n");
368 // Windows spells it Path; a second PATH key would be ambiguous.
369 const pathKey =
370 Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
371 const shadowed = await installOver({
372 script: "install.ps1",
373 home,
374 binDir,
375 env: { [pathKey]: `${otherBin};${process.env[pathKey] ?? ""}` },
376 });
377 expect(shadowed.code, shadowed.output).toBe(0);
378 expect(shadowed.output).toContain(
379 `Another t3 at ${NodePath.join(otherBin, "t3.cmd")} runs first`,
380 );
381​
382 // Its own folder on PATH with a trailing separator is already on PATH.
383 const slashed = await installOver({
384 script: "install.ps1",
385 home,
386 binDir,
387 env: { [pathKey]: `${binDir}\\;${process.env[pathKey] ?? ""}` },
388 });
389 expect(slashed.code, slashed.output).toBe(0);
390 expect(slashed.output).toContain("to use T3 Code here");
391 // The same when the install folder itself is given with a trailing separator.
392 const bothSlashed = await installOver({
393 script: "install.ps1",
394 home,
395 binDir: `${binDir}\\`,
396 env: { [pathKey]: `${binDir}\\;${process.env[pathKey] ?? ""}` },
397 });
398 expect(bothSlashed.code, bothSlashed.output).toBe(0);
399 expect(bothSlashed.output).toContain("to use T3 Code here");
400 } finally {
401 await NodeFSP.rm(root, { recursive: true, force: true });
402 }
403 });
404});
405​
406describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("installer checksum", () => {
407 it.each(
408 ["home\\data", "home\ndata"].flatMap((homeName) =>
409 [true, false].map((validChecksum) => ({ homeName, validChecksum })),
410 ),
411 )(
412 "verifies home $homeName with valid checksum $validChecksum",
413 async ({ homeName, validChecksum }) => {
414 const availableTools = ["sha256sum", "shasum"].filter(
415 (tool) =>
416 NodeChildProcess.spawnSync("sh", ["-c", 'command -v "$1"', "sh", tool]).status === 0,
417 );
418 expect(availableTools.length).toBeGreaterThan(0);
419 for (const checksumTool of availableTools) {
420 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-install-checksum-"));
421 const home = NodePath.join(root, homeName);
422 const version = "1.2.3";
423 const stem = `t3-${version}-linux-${HostProcess.Architecture.defaultValue()}`;
424 const archiveName = `${stem}.tar.gz`;
425 const server = NodeHttp.createServer();
426 try {
427 const tools = NodePath.join(root, "tools");
428 await NodeFSP.mkdir(tools);
429 for (const command of [
430 "sh",
431 "uname",
432 "tar",
433 "gzip",
434 "cut",
435 "curl",
436 "grep",
437 "mkdir",
438 "mktemp",
439 "rm",
440 "mv",
441 "ln",
442 checksumTool,
443 ]) {
444 const executable = NodeChildProcess.execFileSync(
445 "sh",
446 ["-c", 'command -v "$1"', "sh", command],
447 { encoding: "utf8" },
448 ).trim();
449 await NodeFSP.symlink(executable, NodePath.join(tools, command));
450 }
451 await NodeFSP.mkdir(NodePath.join(root, stem));
452 await NodeFSP.writeFile(
453 NodePath.join(root, stem, "t3"),
454 "#!/bin/sh\necho 't3 v1.2.3'\n",
455 { mode: 0o755 },
456 );
457 const archivePath = NodePath.join(root, archiveName);
458 NodeChildProcess.execFileSync("tar", ["-czf", archivePath, "-C", root, stem]);
459 const archive = await NodeFSP.readFile(archivePath);
460 const digest = NodeCrypto.createHash("sha256").update(archive).digest("hex");
461 server.on("request", (request, response) => {
462 response.end(
463 request.url?.endsWith("/SHA256SUMS")
464 ? `${validChecksum ? digest : "0".repeat(64)} ${archiveName}\n`
465 : archive,
466 );
467 });
468 await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
469 const address = server.address();
470 if (!address || typeof address === "string") throw new Error("Expected a TCP listener");
471 const { code, output } = await new Promise<{ code: number | null; output: string }>(
472 (resolve, reject) => {
473 const child = NodeChildProcess.spawn(
474 "sh",
475 [NodePath.resolve(import.meta.dirname, "install.sh")],
476 {
477 env: {
478 ...process.env,
479 PATH: tools,
480 T3CODE_VERSION: version,
481 T3CODE_HOME: home,
482 T3CODE_INSTALL_BIN_DIR: NodePath.join(root, "bin"),
483 T3CODE_RELEASE_BASE_URL: `http://127.0.0.1:${address.port}`,
484 },
485 stdio: ["ignore", "pipe", "pipe"],
486 },
487 );
488 let output = "";
489 child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
490 child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
491 child.on("error", reject);
492 child.on("close", (code) => resolve({ code, output }));
493 },
494 );
495 const versions = NodePath.join(home, "runtime/versions");
496 if (validChecksum) {
497 expect(code, `${checksumTool}: ${output}`).toBe(0);
498 expect(
499 await NodeFSP.readFile(NodePath.join(versions, version, ".install-complete"), "utf8"),
500 ).toBe("1.2.3\n");
501 expect(
502 NodeChildProcess.execFileSync(NodePath.join(root, "bin/t3"), ["--version"], {
503 encoding: "utf8",
504 }).trim(),
505 ).toBe("t3 v1.2.3");
506 } else {
507 expect(code).not.toBe(0);
508 expect(output).toContain("checksum mismatch");
509 expect(output).not.toContain("Installed T3 Code");
510 expect(await NodeFSP.readdir(versions)).toEqual([]);
511 }
512 } finally {
513 server.closeAllConnections();
514 await new Promise<void>((resolve) => server.close(() => resolve()));
515 await NodeFSP.rm(root, { recursive: true, force: true });
516 }
517 }
518 },
519 );
520});