1import { describe, expect, it } from "@effect/vitest";
2import * as NodePath from "@effect/platform-node/NodePath";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as FileSystem from "effect/FileSystem";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
11
12import * as LocalDeviceHost from "./LocalDeviceHost.ts";
13import * as NodeServices from "@effect/platform-node/NodeServices";
14import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15import { HttpClient, HttpClientResponse } from "effect/http";
16import * as NetService from "@t3tools/shared/Net";
17import * as ServerConfig from "../config.ts";
18import * as ProcessRunner from "../processRunner.ts";
19import {
20 AGENT_DEVICE_VERSION,
21 DEVICE_HUB_VERSION,
22 agentDeviceStateDir,
23} from "./DeviceToolchain.ts";
24
25const processOutput = (code: number, stdout = "", stderr = "", timedOut = false) => ({
26 code: ChildProcessSpawner.ExitCode(code),
27 stdout,
28 stderr,
29 timedOut,
30 stdoutTruncated: false,
31 stderrTruncated: false,
32 stdoutInvalidUtf8: false,
33 stderrInvalidUtf8: false,
34});
35
36const agentFixture = Effect.fn("agentFixture")(function* (
37 startup: (
38 stateDir: string,
39 ) => Effect.Effect<ProcessRunner.ProcessRunOutput, ProcessRunner.ProcessRunError>,
40) {
41 const fs = yield* FileSystem.FileSystem;
42 const path = yield* Path.Path;
43 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" });
44 const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata"));
45 for (const [name, version, entry] of [
46 ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"],
47 ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"],
48 ]) {
49 const installDir = path.join(baseDir, "tools", name!, version!);
50 const entryPath = path.join(installDir, "node_modules", name!, entry!);
51 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
52 yield* fs.writeFileString(entryPath, "");
53 yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!);
54 }
55 const host = yield* LocalDeviceHost.make().pipe(
56 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
57 Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }),
58 Effect.provideService(HostProcess.IsExecutable, false),
59 Effect.provideService(HostProcess.Platform, "darwin"),
60 Effect.provideService(
61 ChildProcessSpawner.ChildProcessSpawner,
62 ChildProcessSpawner.make(() =>
63 Effect.succeed(
64 ChildProcessSpawner.makeHandle({
65 pid: ChildProcessSpawner.ProcessId(123),
66 stdout: Stream.empty,
67 stderr: Stream.empty,
68 all: Stream.empty,
69 exitCode: Effect.never,
70 isRunning: Effect.succeed(true),
71 kill: () => Effect.void,
72 stdin: Sink.drain,
73 getInputFd: () => Sink.drain,
74 getOutputFd: () => Stream.empty,
75 unref: Effect.succeed(Effect.void),
76 }),
77 ),
78 ),
79 ),
80 Effect.provideService(ProcessRunner.ProcessRunner, {
81 run: (input) =>
82 input.args.includes("devices") ? startup(stateDir) : Effect.succeed(processOutput(0)),
83 }),
84 Effect.provideService(
85 HttpClient.HttpClient,
86 HttpClient.make((request) =>
87 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))),
88 ),
89 ),
90 );
91 yield* host.ensureReady(() => Effect.void);
92 return { host, fs, stateDir };
93});
94
95describe("AgentDevice startup", () => {
96 it.effect("reports an incompatible lock immediately and retains it for manual repair", () =>
97 Effect.gen(function* () {
98 const result = processOutput(
99 1,
100 JSON.stringify({
101 success: false,
102 error: {
103 message: "private command output",
104 details: {
105 reason: "daemon_registration_unproven",
106 inspection: { state: "unproven", reason: "non-directory" },
107 },
108 },
109 }),
110 );
111 const { host, fs, stateDir } = yield* agentFixture(() => Effect.succeed(result));
112 const lock = `${stateDir}/daemon.lock`;
113 const legacyOwner = JSON.stringify({ pid: 85669, processStartTime: "old start time" });
114 yield* fs.writeFileString(lock, legacyOwner);
115 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
116 expect(error).toMatchObject({
117 _tag: "AgentDeviceStartError",
118 reason: "daemon_lock_not_directory",
119 exitCode: 1,
120 cause: result,
121 });
122 expect(error.message).toContain("Daemon registration ownership could not be verified");
123 expect(error.message).toContain("daemon.lock is not a directory");
124 expect(error.message).toContain(stateDir);
125 expect(error.message).toContain("original CLI");
126 expect(error.message).not.toContain("private command output");
127 expect(yield* fs.readFileString(lock)).toBe(legacyOwner);
128 expect(yield* host.current).not.toBeNull();
129 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
130 );
131
132 it.effect("reports unknown nonzero exits without exposing arbitrary command output", () =>
133 Effect.gen(function* () {
134 const result = processOutput(2, "invalid JSON", "private credentials");
135 const { host, stateDir } = yield* agentFixture(() => Effect.succeed(result));
136 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
137 expect(error).toMatchObject({
138 _tag: "AgentDeviceStartError",
139 reason: "command_failed",
140 exitCode: 2,
141 cause: result,
142 });
143 expect(error.message).toContain("exit code 2");
144 expect(error.message).toContain(stateDir);
145 expect(error.message).not.toContain("private credentials");
146 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
147 );
148
149 it.effect("preserves process execution failures instead of waiting for a daemon file", () =>
150 Effect.gen(function* () {
151 const cause = new ProcessRunner.ProcessSpawnError({
152 command: "/missing/node",
153 argumentCount: 3,
154 cause: new Error("missing executable"),
155 });
156 const { host } = yield* agentFixture(() => Effect.fail(cause));
157 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
158 expect(error).toMatchObject({
159 _tag: "DeviceHostError",
160 step: "starting agent tools",
161 cause,
162 });
163 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
164 );
165
166 it.effect("returns a command timeout without starting another readiness wait", () =>
167 Effect.gen(function* () {
168 const { host } = yield* agentFixture(() => Effect.succeed(processOutput(124, "", "", true)));
169 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
170 expect(error).toMatchObject({ _tag: "DeviceHostTimeoutError", timeoutMs: 30_000 });
171 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
172 );
173
174 it.effect("uses a registered daemon when reading startup command output fails", () =>
175 Effect.gen(function* () {
176 const fs = yield* FileSystem.FileSystem;
177 const cause = new ProcessRunner.ProcessReadError({
178 command: process.execPath,
179 argumentCount: 3,
180 stream: "stdout",
181 cause: new Error("output read failed after registration"),
182 });
183 let attempts = 0;
184 const { host } = yield* agentFixture((stateDir) =>
185 Effect.gen(function* () {
186 attempts++;
187 yield* fs
188 .writeFileString(
189 `${stateDir}/daemon.json`,
190 JSON.stringify({ httpPort: 12345, token: "test-token" }),
191 )
192 .pipe(Effect.orDie);
193 return yield* cause;
194 }),
195 );
196 const ready = yield* host.ensureAgentReady(() => Effect.void);
197 expect(ready.agentDevice).toMatchObject({
198 baseUrl: "http://127.0.0.1:12345",
199 token: "test-token",
200 });
201 expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual(
202 ready.agentDevice,
203 );
204 expect(attempts).toBe(1);
205 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
206 );
207
208 it.effect("allows retry and uses a registered daemon even when discovery exits nonzero", () =>
209 Effect.gen(function* () {
210 const fs = yield* FileSystem.FileSystem;
211 let attempts = 0;
212 const { host } = yield* agentFixture((stateDir) =>
213 Effect.gen(function* () {
214 attempts++;
215 if (attempts > 1) {
216 yield* fs
217 .writeFileString(
218 `${stateDir}/daemon.json`,
219 JSON.stringify({ httpPort: 12345, token: "test-token" }),
220 )
221 .pipe(Effect.orDie);
222 }
223 return processOutput(1);
224 }),
225 );
226 yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
227 const ready = yield* host.ensureAgentReady(() => Effect.void);
228 expect(ready.agentDevice).toMatchObject({
229 baseUrl: "http://127.0.0.1:12345",
230 token: "test-token",
231 });
232 expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual(
233 ready.agentDevice,
234 );
235 expect(attempts).toBe(2);
236 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
237 );
238});
239
240const diagnose = (
241 files: ReadonlyArray<string>,
242 environment: NodeJS.ProcessEnv,
243 platform: NodeJS.Platform = "darwin",
244) =>
245 LocalDeviceHost.__testing.platformReason("android").pipe(
246 Effect.provideService(HostProcess.Environment, environment),
247 Effect.provideService(HostProcess.Platform, platform),
248 Effect.provideService(
249 FileSystem.FileSystem,
250 FileSystem.makeNoop({
251 exists: (file) => Effect.succeed(files.includes(file)),
252 }),
253 ),
254 Effect.provide(platform === "win32" ? NodePath.layerWin32 : NodePath.layerPosix),
255 );
256
257describe("Android SDK availability", () => {
258 it.effect("explains that adb alone is insufficient to launch an emulator", () =>
259 Effect.gen(function* () {
260 const reason = yield* diagnose(["/sdk/platform-tools/adb"], { ANDROID_HOME: "/sdk" });
261 expect(reason).toContain("Android Emulator is missing");
262 }),
263 );
264
265 it.effect("identifies command-line tools required by the device hub", () =>
266 Effect.gen(function* () {
267 const reason = yield* diagnose(["/sdk/platform-tools/adb", "/sdk/emulator/emulator"], {
268 ANDROID_HOME: "/sdk",
269 });
270 expect(reason).toContain("Command-line Tools (latest) are missing");
271 }),
272 );
273
274 it.effect("explains how to upgrade legacy command-line tools in the standard macOS SDK", () =>
275 Effect.gen(function* () {
276 const root = "/test/home/Library/Android/sdk";
277 const reason = yield* diagnose(
278 [`${root}/platform-tools/adb`, `${root}/emulator/emulator`, `${root}/tools/bin/avdmanager`],
279 { HOME: "/test/home" },
280 );
281 expect(reason).toContain("older, unsupported version");
282 expect(reason).toContain(root);
283 expect(reason).toContain(
284 "Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.",
285 );
286 }),
287 );
288
289 it.effect("recognizes legacy command-line tools on Windows", () =>
290 Effect.gen(function* () {
291 const reason = yield* diagnose(
292 [
293 "C:\\Android\\Sdk\\platform-tools\\adb.exe",
294 "C:\\Android\\Sdk\\emulator\\emulator.exe",
295 "C:\\Android\\Sdk\\tools\\bin\\avdmanager.bat",
296 ],
297 { ANDROID_HOME: "C:\\Android\\Sdk" },
298 "win32",
299 );
300 expect(reason).toContain("older, unsupported version");
301 }),
302 );
303
304 it.effect("accepts the latest command-line tools when legacy tools are also installed", () =>
305 Effect.gen(function* () {
306 const reason = yield* diagnose(
307 [
308 "/sdk/platform-tools/adb",
309 "/sdk/emulator/emulator",
310 "/sdk/tools/bin/avdmanager",
311 "/sdk/cmdline-tools/latest/bin/avdmanager",
312 ],
313 { ANDROID_HOME: "/sdk" },
314 );
315 expect(reason).toBeNull();
316 }),
317 );
318
319 it.effect("discovers the standard macOS SDK without ANDROID_HOME", () =>
320 Effect.gen(function* () {
321 const root = "/test/home/Library/Android/sdk";
322 const reason = yield* diagnose(
323 [
324 `${root}/platform-tools/adb`,
325 `${root}/emulator/emulator`,
326 `${root}/cmdline-tools/latest/bin/avdmanager`,
327 ],
328 { HOME: "/test/home" },
329 );
330 expect(reason).toBeNull();
331 }),
332 );
333
334 it.effect("reports an absent SDK without running or installing tools", () =>
335 Effect.gen(function* () {
336 expect(yield* diagnose([], { HOME: "/test/home" })).toContain("Android SDK was not found");
337 }),
338 );
339});
340
341it.effect("puts detected Android tools on the helper PATH without losing existing commands", () =>
342 Effect.gen(function* () {
343 const path = yield* Path.Path;
344 const environment = LocalDeviceHost.__testing.deviceHostEnvironment(
345 { PATH: "/usr/bin", HOME: "/test/home" },
346 "/sdk",
347 "darwin",
348 path,
349 );
350 expect(environment.PATH).toBe("/sdk/platform-tools:/sdk/emulator:/usr/bin");
351 expect(environment.ANDROID_HOME).toBe("/sdk");
352 expect(environment.HOME).toBe("/test/home");
353 }).pipe(Effect.provide(NodePath.layerPosix)),
354);
355
356it.effect(
357 "recovers an owned Linux runtime directory without overriding explicit environments",
358 () =>
359 Effect.gen(function* () {
360 const fs = yield* FileSystem.FileSystem;
361 const directory = yield* fs.makeTempDirectoryScoped();
362 const stat = yield* fs.stat(directory);
363 for (const [platform, uid, runtimeDir, type, owner, expected] of [
364 ["linux", 1000, undefined, "Directory", 1000, "/run/user/1000"],
365 ["linux", 1000, "/custom/runtime", "Directory", 1000, "/custom/runtime"],
366 ["linux", 1000, "", "Directory", 1000, ""],
367 ["linux", 1000, undefined, "Directory", 1001, undefined],
368 ["linux", 1000, undefined, "File", 1000, undefined],
369 ["linux", undefined, undefined, "Directory", 1000, undefined],
370 ["darwin", 1000, undefined, "Directory", 1000, undefined],
371 ["win32", 1000, undefined, "Directory", 1000, undefined],
372 ] as const) {
373 const environment = { PATH: "/usr/bin", XDG_RUNTIME_DIR: runtimeDir };
374 const result = yield* LocalDeviceHost.__testing.hubEnvironment(environment).pipe(
375 Effect.provideService(HostProcess.Platform, platform),
376 Effect.provideService(HostProcess.UserId, uid),
377 Effect.provideService(
378 FileSystem.FileSystem,
379 FileSystem.makeNoop({
380 stat: (path) => {
381 expect(path).toBe("/run/user/1000");
382 return Effect.succeed({ ...stat, type, uid: Option.some(owner) });
383 },
384 }),
385 ),
386 );
387 expect(result.XDG_RUNTIME_DIR).toBe(expected);
388 expect(result.PATH).toBe("/usr/bin");
389 expect(result.FORCE_COLOR).toBe("0");
390 expect(result.NO_COLOR).toBe("1");
391 expect(environment.XDG_RUNTIME_DIR).toBe(runtimeDir);
392 }
393 const missing = yield* LocalDeviceHost.__testing
394 .hubEnvironment({})
395 .pipe(
396 Effect.provideService(HostProcess.Platform, "linux"),
397 Effect.provideService(HostProcess.UserId, 1000),
398 Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
399 );
400 expect(missing.XDG_RUNTIME_DIR).toBeUndefined();
401 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
402);
403
404it.effect(
405 "constructs and inspects an unconfigured host without installing or starting helpers",
406 () =>
407 Effect.gen(function* () {
408 const fs = yield* FileSystem.FileSystem;
409 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-consent-" });
410 const host = yield* LocalDeviceHost.make().pipe(
411 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
412 Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }),
413 Effect.provideService(HostProcess.Platform, "linux"),
414 Effect.provideService(
415 ChildProcessSpawner.ChildProcessSpawner,
416 ChildProcessSpawner.make(() =>
417 Effect.die(new Error("Host construction must not spawn processes")),
418 ),
419 ),
420 Effect.provideService(ProcessRunner.ProcessRunner, {
421 run: () => Effect.die(new Error("Host construction must not run commands")),
422 }),
423 Effect.provideService(
424 HttpClient.HttpClient,
425 HttpClient.make(() =>
426 Effect.die(new Error("Host construction must not make network requests")),
427 ),
428 ),
429 );
430 expect(yield* host.current).toBeNull();
431 const error = yield* host
432 .ensureReady(() => Effect.die("Must not install without Node"))
433 .pipe(Effect.flip, Effect.provideService(HostProcess.IsExecutable, true));
434 expect(error.message).toContain("Local device support requires Node.js");
435 expect(error.message).toContain("Install Node.js");
436 yield* host.stop;
437 expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);
438 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
439);