1import * as Effect from "effect/Effect";
2import * as NodePathLayer from "@effect/platform-node/NodePath";
3import * as ProcessRunner from "../processRunner.ts";
4import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
5// @effect-diagnostics nodeBuiltinImport:off - tests the same standalone script used by local and SSH hosts.
6import { describe, expect, it } from "@effect/vitest";
7import * as NodeFSP from "node:fs/promises";
8import * as NodeOS from "node:os";
9import * as NodePath from "node:path";
10import * as NodeChildProcess from "node:child_process";
11import * as NodeUtil from "node:util";
12import { pruneLocalDeviceTools, deviceToolMaintenanceScript } from "./deviceToolMaintenance.ts";
13
14const exec = NodeUtil.promisify(NodeChildProcess.execFile);
15
16describe.each([false, true])("device tool cleanup, flat=%s", (flat) => {
17 it("keeps current, previous, active and incomplete installs, pruning unused completed versions", async () => {
18 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-"));
19 const name = "expo-device-hub";
20 const directory = (version: string) =>
21 flat ? NodePath.join(root, `${name}@${version}`) : NodePath.join(root, name, version);
22 try {
23 for (const version of ["0.1.0", "0.2.0", "0.3.0", "0.4.0", "0.5.0", "0.6.0"]) {
24 await NodeFSP.mkdir(directory(version), { recursive: true });
25 if (version === "0.5.0") continue;
26 const sentinel = NodePath.join(directory(version), ".install-complete");
27 await NodeFSP.writeFile(sentinel, version);
28 await NodeFSP.utimes(
29 sentinel,
30 Number(version.split(".")[1]),
31 Number(version.split(".")[1]),
32 );
33 }
34 const script =
35 deviceToolMaintenanceScript +
36 `
37(async () => {
38 const root = ${JSON.stringify(root)};
39 await pruneTools(root, [['${name}', '0.6.0']], ${flat});
40})().catch(error => { console.error(error); process.exitCode = 1; });`;
41 await exec(process.execPath, [
42 "-e",
43 script,
44 NodePath.join(directory("0.2.0"), "active-helper.cjs"),
45 ]);
46 await expect(NodeFSP.stat(directory("0.1.0"))).rejects.toThrow();
47 await expect(NodeFSP.stat(directory("0.3.0"))).rejects.toThrow();
48 for (const version of ["0.2.0", "0.4.0", "0.5.0", "0.6.0"])
49 expect((await NodeFSP.stat(directory(version))).isDirectory()).toBe(true);
50 } finally {
51 await NodeFSP.rm(root, { recursive: true, force: true });
52 }
53 });
54
55 it("keeps every install when the process scan fails", async () => {
56 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-scan-"));
57 try {
58 for (const version of ["0.1.0", "0.2.0", "0.3.0"]) {
59 const dir = flat
60 ? NodePath.join(root, `expo-device-hub@${version}`)
61 : NodePath.join(root, "expo-device-hub", version);
62 await NodeFSP.mkdir(dir, { recursive: true });
63 await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), version);
64 }
65 await exec(process.execPath, [
66 "-e",
67 deviceToolMaintenanceScript +
68 `
69 require('node:child_process').spawnSync = () => ({ status: 1, stdout: '' });
70 pruneTools(${JSON.stringify(root)}, [['expo-device-hub', '0.3.0']], ${flat}).catch(() => process.exitCode = 1);
71 `,
72 ]);
73 const parent = flat ? root : NodePath.join(root, "expo-device-hub");
74 expect((await NodeFSP.readdir(parent)).length).toBe(3);
75 } finally {
76 await NodeFSP.rm(root, { recursive: true, force: true });
77 }
78 });
79
80 it("does not prune before the required version has completed installation", async () => {
81 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-cleanup-"));
82 try {
83 const dir = flat
84 ? NodePath.join(root, "expo-device-hub@0.1.0")
85 : NodePath.join(root, "expo-device-hub/0.1.0");
86 await NodeFSP.mkdir(dir, { recursive: true });
87 await NodeFSP.writeFile(NodePath.join(dir, ".install-complete"), "0.1.0");
88 await exec(process.execPath, [
89 "-e",
90 deviceToolMaintenanceScript +
91 `pruneTools(${JSON.stringify(root)}, [['expo-device-hub','0.6.0']], ${flat}).catch(() => process.exitCode = 1);`,
92 ]);
93 expect((await NodeFSP.stat(dir)).isDirectory()).toBe(true);
94 } finally {
95 await NodeFSP.rm(root, { recursive: true, force: true });
96 }
97 });
98});
99
100it.effect("maintenance failures retain safe context and the original process result", () =>
101 Effect.gen(function* () {
102 const output = {
103 code: ChildProcessSpawner.ExitCode(1),
104 stdout: "",
105 stderr: "private child diagnostics",
106 timedOut: false,
107 stdoutTruncated: false,
108 stderrTruncated: false,
109 stdoutInvalidUtf8: false,
110 stderrInvalidUtf8: false,
111 };
112 for (const [operation, run] of [["prune", pruneLocalDeviceTools]] as const) {
113 const error = yield* run("/tools", process.execPath, "hub").pipe(
114 Effect.provideService(ProcessRunner.ProcessRunner, { run: () => Effect.succeed(output) }),
115 Effect.flip,
116 );
117 expect(error).toMatchObject({
118 _tag: "DeviceToolMaintenanceError",
119 operation,
120 tool: "hub",
121 exitCode: 1,
122 cause: output,
123 });
124 expect(error.message).toBe(`Device tool ${operation} failed for hub (exit code 1).`);
125 expect(error.message).not.toContain(output.stderr);
126 }
127 }).pipe(Effect.provide(NodePathLayer.layer)),
128);
129
130it("serializes competing maintenance processes after reclaiming a stale lock", async () => {
131 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tool-contention-"));
132 try {
133 const lock = NodePath.join(root, ".maintenance-lock");
134 await NodeFSP.mkdir(lock);
135 await NodeFSP.writeFile(
136 NodePath.join(lock, "stale-owner.json"),
137 JSON.stringify({ pid: 2147483647, identity: "dead" }),
138 );
139 const script =
140 deviceToolMaintenanceScript +
141 `
142(async () => {
143 const root = ${JSON.stringify(root)};
144 const marker = maintenancePath.join(root, 'critical-section');
145 for (let attempt = 0; attempt < 8; attempt++) await withToolMaintenance(root, () => {
146 maintenanceFs.writeFileSync(marker, String(process.pid), { flag: 'wx' });
147 for (let check = 0; check < 100; check++) {
148 if (maintenanceFs.readFileSync(marker, 'utf8') !== String(process.pid)) throw Error('Overlapping maintenance');
149 }
150 maintenanceFs.unlinkSync(marker);
151 });
152})().catch(error => { console.error(error); process.exitCode = 1; });`;
153 await Promise.all(Array.from({ length: 6 }, () => exec(process.execPath, ["-e", script])));
154 await expect(NodeFSP.stat(lock)).rejects.toThrow();
155 expect(await NodeFSP.readdir(root)).toEqual([]);
156 } finally {
157 await NodeFSP.rm(root, { recursive: true, force: true });
158 }
159});