History

feat(devices): connect simulator hosts over SSH (#10856)

Julius Marminge committed 2 months ago

Commit d2eeacd1 parent 7734c6d30 files · +1794 −78Browse files

Showing the changes to apps/server/src/device/sshDeviceScript.test.ts (1 of 30 files). Show all

Unified · Split

apps/server/src/device/sshDeviceScript.test.ts+220−0
@@ -0,0 +1,220 @@
1+// @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off preferSchemaOverJson:off - verifies generated remote scripts using real shell and Node processes.
2+import * as Effect from "effect/Effect";
3+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4+import { describe, expect, it } from "@effect/vitest";
5+import * as NodeChildProcess from "node:child_process";
6+import * as NodeFSP from "node:fs/promises";
7+import * as NodeOS from "node:os";
8+import * as NodePath from "node:path";
9+import * as NodeUtil from "node:util";
10+import { quoteRemoteArg, remoteDeviceEnvironment, remoteDeviceScript } from "./sshDeviceScript.ts";
11+import { AGENT_DEVICE_VERSION, DEVICE_HUB_VERSION } from "./DeviceToolchain.ts";
12+
13+const exec = NodeUtil.promisify(NodeChildProcess.execFile);
14+
15+it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
16+ Effect.gen(function* () {
17+ if ((yield* HostProcessPlatform) === "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+
39+it.effect("preserves shell metacharacters and newlines in remote arguments", () =>
40+ Effect.gen(function* () {
41+ if ((yield* HostProcessPlatform) === "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+
50+describe("remote helper lifecycle", () => {
51+ it.effect("reuses its own healthy helpers and stops only its own runtime", () =>
52+ Effect.gen(function* () {
53+ if ((yield* HostProcessPlatform) === "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';
75+if(fs.existsSync('fail-start-once')) {fs.unlinkSync('fail-start-once');process.exit(1);}
76+const 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';
81+const args=process.argv.slice(2);
82+const state=process.env.AGENT_DEVICE_STATE_DIR || args[args.indexOf('--state-dir')+1];
83+const file=path.join(state,'daemon.json');
84+if(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 {} }
85+else 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?.();}); }
86+else { const child=spawn(process.execPath,[process.argv[1],'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+ const nextHubVersion = DEVICE_HUB_VERSION + "-upgrade";
90+ const nextAgentVersion = AGENT_DEVICE_VERSION + "-upgrade";
91+ let invocation = 0;
92+ const invoke = async (
93+ owner: string,
94+ mode: "start" | "agent-start" | "stop-agent" | "stop",
95+ upgraded = false,
96+ ) => {
97+ const file = NodePath.join(home, `${owner}-${mode}-${invocation++}.cjs`);
98+ await NodeFSP.writeFile(
99+ file,
100+ `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` +
101+ remoteDeviceScript(owner, mode)
102+ .replace(DEVICE_HUB_VERSION, upgraded ? nextHubVersion : DEVICE_HUB_VERSION)
103+ .replace(AGENT_DEVICE_VERSION, upgraded ? nextAgentVersion : AGENT_DEVICE_VERSION),
104+ );
105+ const result = await exec(process.execPath, [file], {
106+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
107+ });
108+ return result.stdout ? JSON.parse(result.stdout) : null;
109+ };
110+ const template = NodePath.join(home, "hub-template");
111+ await NodeFSP.cp(hubDir, template, { recursive: true });
112+ await NodeFSP.rm(NodePath.join(hubDir, ".install-complete"));
113+ const installLock = hubDir + ".lock";
114+ await NodeFSP.symlink("2147483647:exited-installer", installLock);
115+ await NodeFSP.writeFile(
116+ NodePath.join(bin, "npm"),
117+ `#!${process.execPath}\nconst fs=require('node:fs');const args=process.argv.slice(2);fs.cpSync(${JSON.stringify(template)},args[args.indexOf('--prefix')+1],{recursive:true});`,
118+ { mode: 0o755 },
119+ );
120+ await NodeFSP.mkdir(NodePath.join(root, "hosts/one"), { recursive: true });
121+ await NodeFSP.writeFile(NodePath.join(root, "hosts/one/fail-start-once"), "");
122+ try {
123+ const [manual, concurrent] = await Promise.all([
124+ invoke("one", "start"),
125+ invoke("one", "start"),
126+ ]);
127+ expect(concurrent.hubPort).toBe(manual.hubPort);
128+ expect(manual.daemonPort).toBeUndefined();
129+ await expect(
130+ NodeFSP.stat(NodePath.join(root, "hosts/one/daemon.json")),
131+ ).rejects.toThrow();
132+ const [first, concurrentAgent] = await Promise.all([
133+ invoke("one", "agent-start"),
134+ invoke("one", "agent-start"),
135+ ]);
136+ expect(concurrentAgent.hubPort).toBe(first.hubPort);
137+ expect(concurrentAgent.daemonPort).toBe(first.daemonPort);
138+ const second = await invoke("two", "agent-start");
139+ const reused = await invoke("one", "agent-start");
140+ expect(reused.hubPort).toBe(first.hubPort);
141+ expect(reused.daemonPort).toBe(first.daemonPort);
142+ expect(second.hubPort).not.toBe(first.hubPort);
143+ expect(second.daemonPort).not.toBe(first.daemonPort);
144+ const firstHub = JSON.parse(
145+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
146+ );
147+ const secondHub = JSON.parse(
148+ await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"),
149+ );
150+ await NodeFSP.writeFile(NodePath.join(root, `hosts/one/unhealthy-${firstHub.pid}`), "");
151+ let repaired = await invoke("one", "agent-start");
152+ expect(repaired.hubPort).not.toBe(first.hubPort);
153+ const stopped = (await NodeFSP.readFile(NodePath.join(home, "stops"), "utf8"))
154+ .trim()
155+ .split("\n");
156+ expect(stopped).toContain(String(firstHub.pid));
157+ expect(stopped).not.toContain(String(secondHub.pid));
158+ const previousDaemon = JSON.parse(
159+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
160+ );
161+ for (const [source, name, version] of [
162+ [hubDir, "expo-device-hub", nextHubVersion],
163+ [agentDir, "agent-device", nextAgentVersion],
164+ ]) {
165+ const destination = NodePath.join(root, `tools/${name}@${version}`);
166+ await NodeFSP.cp(source!, destination, { recursive: true });
167+ await NodeFSP.writeFile(NodePath.join(destination, ".install-complete"), version!);
168+ }
169+ const upgraded = await invoke("one", "agent-start", true);
170+ expect(upgraded.entryPath).toContain(nextAgentVersion);
171+ const upgradedHub = JSON.parse(
172+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/hub.json"), "utf8"),
173+ );
174+ expect(upgradedHub.entryPath).toContain(nextHubVersion);
175+ const upgradedDaemon = JSON.parse(
176+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
177+ );
178+ expect(upgradedDaemon.pid).not.toBe(previousDaemon.pid);
179+ expect(await invoke("one", "agent-start", true)).toEqual(upgraded);
180+ await NodeFSP.writeFile(
181+ NodePath.join(root, `hosts/one/unhealthy-agent-${upgradedDaemon.pid}`),
182+ "",
183+ );
184+ repaired = await invoke("one", "agent-start", true);
185+ expect(
186+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
187+ ).toBe(String(upgradedDaemon.pid));
188+ expect(repaired.daemonPort).not.toBe(upgraded.daemonPort);
189+ // Stop still uses the recorded entry when a future pinned package is not installed yet.
190+ const originalScript = remoteDeviceScript("one", "stop-agent");
191+ const upgradedStop = NodePath.join(home, "upgraded-stop.cjs");
192+ await NodeFSP.writeFile(
193+ upgradedStop,
194+ originalScript.replace(AGENT_DEVICE_VERSION, "999.0.0"),
195+ );
196+ await exec(process.execPath, [upgradedStop], {
197+ env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
198+ });
199+ const daemon = JSON.parse(
200+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/daemon.json"), "utf8"),
201+ );
202+ expect(
203+ await NodeFSP.readFile(NodePath.join(root, "hosts/one/stopped-agent"), "utf8"),
204+ ).toBe(String(daemon.pid));
205+ expect((await fetch(`http://127.0.0.1:${repaired.hubPort}/readyz`)).ok).toBe(true);
206+ await invoke("one", "stop");
207+ expect((await fetch(`http://127.0.0.1:${second.hubPort}/readyz`)).ok).toBe(true);
208+ expect(
209+ JSON.parse(await NodeFSP.readFile(NodePath.join(root, "hosts/two/hub.json"), "utf8"))
210+ .owner,
211+ ).toBe("two");
212+ } finally {
213+ await invoke("one", "stop").catch(() => {});
214+ await invoke("two", "stop").catch(() => {});
215+ await NodeFSP.rm(home, { recursive: true, force: true });
216+ }
217+ });
218+ }),
219+ );
220+});