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.
2import * as Effect from "effect/Effect";
3import { HostProcessPlatform } 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* 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
39it.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
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* 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';
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,[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});