1// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - verifies generated remote scripts using real shell and Node processes.
2import * as Effect from "effect/Effect";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import { describe, expect, it } from "@effect/vitest";
5import * as NodeChildProcess from "node:child_process";
6import * as NodeFSP from "node:fs/promises";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeUtil from "node:util";
10import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
11import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
12
13const exec = NodeUtil.promisify(NodeChildProcess.execFile);
14
15it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
16 Effect.gen(function* () {
17 if ((yield* HostProcess.Platform) === "win32") return;
18 yield* Effect.promise(async () => {
19 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-"));
20 try {
21 const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr");
22 await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true });
23 await NodeFSP.writeFile(
24 NodePath.join(javaHome, "bin/java"),
25 "#!/bin/sh\necho test-java\n",
26 { mode: 0o755 },
27 );
28 const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], {
29 env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" },
30 });
31 expect(result.stdout.trim()).toBe("test-java");
32 } finally {
33 await NodeFSP.rm(home, { recursive: true, force: true });
34 }
35 });
36 }),
37);
38
39it.effect("preserves shell metacharacters and newlines in remote arguments", () =>
40 Effect.gen(function* () {
41 if ((yield* HostProcess.Platform) === "win32") return;
42 yield* Effect.promise(async () => {
43 const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line";
44 const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]);
45 expect(result.stdout).toBe(value);
46 });
47 }),
48);
49
50describe("remote helper lifecycle", () => {
51 it.effect("reuses its own healthy helpers and stops only its own runtime", () =>
52 Effect.gen(function* () {
53 if ((yield* HostProcess.Platform) === "win32") return;
54 yield* Effect.promise(async () => {
55 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-remote-script-"));
56 const bin = NodePath.join(home, "bin");
57 await NodeFSP.mkdir(bin);
58 await NodeFSP.writeFile(NodePath.join(bin, "adb"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
59 const root = NodePath.join(home, ".t3/device");
60 const hubDir = NodePath.join(root, `tools/expo-device-hub@${DEVICE_HUB_VERSION}`);
61 const agentDir = NodePath.join(root, `tools/agent-device@${AGENT_DEVICE_VERSION}`);
62 const hub = NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server/cli.mjs");
63 const agent = NodePath.join(agentDir, "node_modules/agent-device/bin/agent-device.mjs");
64 await NodeFSP.mkdir(NodePath.join(hubDir, "node_modules/expo-device-hub/dist/server"), {
65 recursive: true,
66 });
67 await NodeFSP.mkdir(NodePath.join(agentDir, "node_modules/agent-device/bin"), {
68 recursive: true,
69 });
70 await NodeFSP.writeFile(NodePath.join(hubDir, ".install-complete"), DEVICE_HUB_VERSION);
71 await NodeFSP.writeFile(NodePath.join(agentDir, ".install-complete"), AGENT_DEVICE_VERSION);
72 await NodeFSP.writeFile(
73 hub,
74 `import http from 'node:http'; import fs from 'node:fs';
75if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);}
76const args=process.argv.slice(2); http.createServer((req,res)=>{res.statusCode=fs.existsSync('unhealthy-'+process.pid)?503:200;res.end('ok');}).listen(Number(args[args.indexOf('--port')+1]),'127.0.0.1');`,
77 );
78 await NodeFSP.writeFile(
79 agent,
80 `import fs from 'node:fs'; import path from 'node:path'; import http from 'node:http'; import {spawn} from 'node:child_process';
81const args=process.argv.slice(2);
82const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1];
83const file=path.join(state,'daemon.json');
84if(args[0]==='daemon') { const data=JSON.parse(fs.readFileSync(file,'utf8')); fs.writeFileSync(path.join(state,'stopped-agent'),String(data.pid)); try {process.kill(data.pid,'SIGTERM')} catch {} }
85else if(args[0]==='serve') { const server=http.createServer((req,res)=>{res.statusCode=fs.existsSync(path.join(state,'unhealthy-agent-'+process.pid))?503:200;res.end('ok');}); server.listen(0,'127.0.0.1',()=>{fs.writeFileSync(file,JSON.stringify({httpPort:server.address().port,pid:process.pid,token:'test'}));process.send?.('ready');process.disconnect?.();}); }
86else { const child=spawn(process.execPath,[path.join(path.dirname(process.argv[1]),'daemon.mjs'),'serve'],{detached:true,stdio:['ignore','ignore','ignore','ipc'],env:process.env});await new Promise((resolve,reject)=>{child.once('message',resolve);child.once('error',reject);});child.unref(); }
87`,
88 );
89 await NodeFSP.copyFile(agent, NodePath.join(NodePath.dirname(agent), "daemon.mjs"));
90 const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
91 const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
92 let invocation = 0;
93 const invoke = async (
94 owner: string,
95 mode: "probe" | "start" | "agent-start" | "stop-agent" | "stop",
96 upgraded = false,
97 ) => {
98 const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
99 await NodeFSP.writeFile(
100 file,
101 `const originalKill = process.kill; process.kill = (pid, signal) => { if (signal === 'SIGTERM') require('node:fs').appendFileSync(${JSON.stringify(NodePath.join(home, "stops"))}, pid+'\\n'); return originalKill(pid, signal); };\n` +
102 remoteDeviceScript(owner, mode)
103 .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION)
104 .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION),
105 );
106 const result = await exec(process.execPath, [file], {
107 env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
108 });
109 return result.stdout ? JSON.parse(result.stdout) : null;
110 };
111 const inventory = await invoke("one", "probe");
112 expect(inventory.tools.hub.installedVersions).toEqual([DEVICE_HUB_VERSION]);
113 expect(inventory.tools.hub.runningVersion).toBeNull();
114 expect(inventory.tools.agent.installedVersions).toEqual([AGENT_DEVICE_VERSION]);
115 await expect(NodeFSP.stat(NodePath.join(root, "hosts/one/hub.json"))).rejects.toThrow();
116 const template = NodePath.join(home, "hub-template");
117 await NodeFSP.cp(hubDir, template, { recursive: true });
118 await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
119 const installLock = hubDir + ".lock";
120 await NodeFSP.symlink("2147483647:exited-installer", installLock);
121 await NodeFSP.writeFile(
122 NodePath.join(bin, "npm"),
123 `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);if(args[0]==='--version'){console.log('10.0.0');process.exit(0);}fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
124 { mode: 0o755 },
125 );
126 await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
127 await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), "");
128 // Unavailable advisory bookkeeping must not prevent either helper from starting.
129 await NodeFSP.writeFile(NodePath.join(root, "tools/.maintenance-lock"), "blocked");
130 await NodeFSP.writeFile(NodePath.join(root, "tools/.users"), "unwritable lease directory");
131 try {
132 const [manual, concurrent] = await Promise.all([
133 invoke("one", "start"),
134 invoke("one", "start"),
135 ]);
136 expect(concurrent.hubPort).toBe(manual.hubPort);
137 expect(manual.daemonPort).toBeUndefined();
138 await expect(
139 NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")),
140 ).rejects.toThrow();
141 const [first, concurrentAgent] = await Promise.all([
142 invoke("one", "agent-start"),
143 invoke("one", "agent-start"),
144 ]);
145 expect(concurrentAgent.hubPort).toBe(first.hubPort);
146 expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
147 const running = await invoke("one", "probe");
148 expect(running.tools.hub.runningVersion).toBe(DEVICE_HUB_VERSION);
149 expect(running.tools.agent.runningVersion).toBe(AGENT_DEVICE_VERSION);
150 const second = await invoke("two", "agent-start");
151 const reused = await invoke("one", "agent-start");
152 expect(reused.hubPort).toBe(first.hubPort);
153 expect(reused.daemonPort).toBe(first.daemonPort);
154 expect(second.hubPort).not.toBe(first.hubPort);
155 expect(second.daemonPort).not.toBe(first.daemonPort);
156 const firstHub = JSON.parse(
157 await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
158 );
159 const secondHub = JSON.parse(
160 await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"),
161 );
162 await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), "");
163 let repaired = await invoke("one", "agent-start");
164 expect(repaired.hubPort).not.toBe(first.hubPort);
165 const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8"))
166 .trim()
167 .split("\n");
168 expect(stopped).toContain(String(firstHub.pid));
169 expect(stopped).not.toContain(String(secondHub.pid));
170 const previousDaemon = JSON.parse(
171 await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
172 );
173 for (const [source, name, version] of [
174 [hubDir, "expo-device-hub", nextHubVersion],
175 [agentDir, "agent-device", nextAgentVersion],
176 ]) {
177 const destination = NodePath.join(root, `tools/${name}@${version}`);
178 await NodeFSP.cp(source!, destination, { recursive: true });
179 await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!);
180 }
181 const upgraded = await invoke("one", "agent-start", true);
182 expect(upgraded.entryPath).toContain(nextAgentVersion);
183 const upgradedHub = JSON.parse(
184 await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
185 );
186 expect(upgradedHub.entryPath).toContain(nextHubVersion);
187 const upgradedDaemon = JSON.parse(
188 await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
189 );
190 expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid);
191 expect(await invoke("one", "agent-start", true)).toEqual(upgraded);
192 await NodeFSP.writeFile(
193 NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`),
194 "",
195 );
196 repaired = await invoke("one", "agent-start", true);
197 expect(
198 await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
199 ).toBe(String(upgradedDaemon.pid));
200 expect(repaired.daemonPort).not.toBe(upgraded.daemonPort);
201 // Stop still uses the recorded entry when a future pinned package is not installed yet.
202 const originalScript = remoteDeviceScript("one", "stop-agent");
203 const upgradedStop = NodePath.join(home, "upgraded-stop.cjs");
204 await NodeFSP.writeFile(
205 upgradedStop,
206 originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"),
207 );
208 await exec(process.execPath, [upgradedStop], {
209 env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
210 });
211 const daemon = JSON.parse(
212 await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
213 );
214 expect(
215 await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
216 ).toBe(String(daemon.pid));
217 expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true);
218 await invoke("one", "stop");
219 expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true);
220 expect(
221 JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"))
222 .owner,
223 ).toBe("two");
224 } finally {
225 await invoke("one", "stop").catch(() => {});
226 await invoke("two", "stop").catch(() => {});
227 await NodeFSP.rm(home, { recursive: true, force: true });
228 }
229 });
230 }),
231 );
232});