Skip to content
Source
Sign inSign up

sshDeviceScript.test.ts

149 lines6.5 KB

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 { 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";
11​
12const exec = NodeUtil.promisify(NodeChildProcess.execFile);
13​
14it.effect("finds Android Studio Java for a non-interactive SSH session", () =>
15 Effect.gen(function* () {
16 if ((yield* HostProcess.Platform) === "win32") return;
17 yield* Effect.promise(async () => {
18 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-java-"));
19 try {
20 const javaHome = NodePath.join(home, ".local/opt/android-studio/jbr");
21 await NodeFSP.mkdir(NodePath.join(javaHome, "bin"), { recursive: true });
22 await NodeFSP.writeFile(
23 NodePath.join(javaHome, "bin/java"),
24 "#!/bin/sh\necho test-java\n",
25 { mode: 0o755 },
26 );
27 const result = await exec("/bin/sh", ["-c", `${remoteDeviceEnvironment}\njava`], {
28 env: { HOME: home, PATH: "/nonexistent", JAVA_HOME: "" },
29 });
30 expect(result.stdout.trim()).toBe("test-java");
31 } finally {
32 await NodeFSP.rm(home, { recursive: true, force: true });
33 }
34 });
35 }),
36);
37​
38it.effect("preserves shell metacharacters and newlines in remote arguments", () =>
39 Effect.gen(function* () {
40 if ((yield* HostProcess.Platform) === "win32") return;
41 yield* Effect.promise(async () => {
42 const value = "quotes ' \" ; $(echo expanded) $HOME\nnext line";
43 const result = await exec("sh", ["-c", `printf %s ${quoteRemoteArg(value)}`]);
44 expect(result.stdout).toBe(value);
45 });
46 }),
47);
48​
49it.effect("rejects a Node release below the pinned agent-device engine floor", () =>
50 Effect.gen(function* () {
51 if ((yield* HostProcess.Platform) === "win32") return;
52 const probe = NodeChildProcess.spawnSync(process.execPath, [], {
53 input:
54 `Object.defineProperty(process.versions, "node", { value: "22.11.0" });\n` +
55 remoteDeviceScript("owner", "probe"),
56 env: { HOME: NodeOS.tmpdir(), PATH: NodePath.dirname(process.execPath) },
57 encoding: "utf8",
58 });
59 expect(probe.status).not.toBe(0);
60 expect(probe.stderr).toContain("Node 22.12 or newer");
61 }),
62);
63​
64it.effect("stops a daemon left for its owner when starting without agent tools", () =>
65 Effect.gen(function* () {
66 if ((yield* HostProcess.Platform) === "win32") return;
67 yield* Effect.promise(async () => {
68 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-start-"));
69 try {
70 const state = NodePath.join(home, ".t3/device/hosts/owner");
71 const daemonFile = NodePath.join(state, "daemon.json");
72 const calls = NodePath.join(home, "calls");
73 const failMarker = NodePath.join(home, "fail-stop");
74 const entry = NodePath.join(home, "agent-device.mjs");
75 await NodeFSP.mkdir(NodePath.join(home, "bin"));
76 await NodeFSP.mkdir(state, { recursive: true });
77 await NodeFSP.writeFile(
78 entry,
79 `import * as fs from "node:fs";
80fs.appendFileSync(${JSON.stringify(calls)}, process.argv.slice(2).join(" ") + "\\n");
81if (fs.existsSync(${JSON.stringify(failMarker)})) process.exitCode = 1;`,
82 );
83 await NodeFSP.writeFile(
84 NodePath.join(state, "agent.json"),
85 JSON.stringify({ entryPath: entry }),
86 );
87 await NodeFSP.writeFile(daemonFile, JSON.stringify({ httpPort: 1, token: "claims" }));
88 // An empty PATH leaves no platforms, so the script ends right after the daemon check.
89 const start = () =>
90 NodeChildProcess.spawnSync(process.execPath, [], {
91 input: remoteDeviceScript("owner", "start"),
92 env: { HOME: home, PATH: NodePath.join(home, "bin") },
93 encoding: "utf8",
94 });
95 await NodeFSP.writeFile(failMarker, "");
96 expect(start().stderr).toContain("Could not stop the agent-device daemon");
97 expect(await NodeFSP.readFile(daemonFile, "utf8")).toContain("claims");
98 await NodeFSP.rm(failMarker);
99 expect(start().stderr).toContain("Android SDK missing");
100 await expect(NodeFSP.access(daemonFile)).rejects.toThrow();
101 expect(await NodeFSP.readFile(calls, "utf8")).toBe(
102 `daemon stop --state-dir ${state}\ndaemon stop --state-dir ${state}\n`,
103 );
104 } finally {
105 await NodeFSP.rm(home, { recursive: true, force: true });
106 }
107 });
108 }),
109);
110​
111it.effect("keeps a daemon record its missing agent CLI cannot stop", () =>
112 Effect.gen(function* () {
113 if ((yield* HostProcess.Platform) === "win32") return;
114 yield* Effect.promise(async () => {
115 const home = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-ssh-start-"));
116 try {
117 const state = NodePath.join(home, ".t3/device/hosts/owner");
118 const daemonFile = NodePath.join(state, "daemon.json");
119 await NodeFSP.mkdir(NodePath.join(home, "bin"));
120 await NodeFSP.mkdir(state, { recursive: true });
121 await NodeFSP.writeFile(
122 NodePath.join(state, "agent.json"),
123 JSON.stringify({ entryPath: NodePath.join(home, "missing/agent-device.mjs") }),
124 );
125 const start = async (pid: number) => {
126 await NodeFSP.writeFile(
127 daemonFile,
128 JSON.stringify({ pid, httpPort: 1, token: "claims" }),
129 );
130 return NodeChildProcess.spawnSync(process.execPath, [], {
131 input: remoteDeviceScript("owner", "start"),
132 env: { HOME: home, PATH: NodePath.join(home, "bin") },
133 encoding: "utf8",
134 });
135 };
136 // This test process stands in for a daemon that is still running.
137 expect((await start(process.pid)).stderr).toContain(
138 "Could not stop the agent-device daemon",
139 );
140 expect(await NodeFSP.readFile(daemonFile, "utf8")).toContain("claims");
141 const exited = NodeChildProcess.spawnSync(process.execPath, ["-e", ""]).pid;
142 expect((await start(exited)).stderr).toContain("Android SDK missing");
143 await expect(NodeFSP.access(daemonFile)).rejects.toThrow();
144 } finally {
145 await NodeFSP.rm(home, { recursive: true, force: true });
146 }
147 });
148 }),
149);