| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as NodePath from "@effect/platform-node/NodePath"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Sink from "effect/Sink"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | |
| 12 | import * as LocalDeviceHost from "./LocalDeviceHost.ts"; |
| 13 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 14 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 15 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 16 | import * as NetService from "@t3tools/shared/Net"; |
| 17 | import * as ServerConfig from "../config.ts"; |
| 18 | import * as ProcessRunner from "../processRunner.ts"; |
| 19 | import { |
| 20 | AGENT_DEVICE_VERSION, |
| 21 | DEVICE_HUB_VERSION, |
| 22 | agentDeviceStateDir, |
| 23 | } from "./DeviceToolchain.ts"; |
| 24 | |
| 25 | const 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 | |
| 36 | const 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 | |
| 95 | describe("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 | |
| 240 | const 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 | |
| 257 | describe("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 | |
| 341 | it.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 | |
| 356 | it.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 | |
| 404 | it.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 | ); |