Showing the changes to apps/server/src/device/sshDeviceScript.test.ts (1 of 30 files). Show all
Unified · Split
| @@ -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 | +}); | |