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 PlatformError from "effect/PlatformError";
9import * as Ref from "effect/Ref";
10import * as FileSystem from "effect/FileSystem";
11import * as Sink from "effect/Sink";
12import * as Stream from "effect/Stream";
13
14import * as LocalDeviceHost from "./LocalDeviceHost.ts";
15import * as NodeServices from "@effect/platform-node/NodeServices";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import { HttpClient, HttpClientResponse } from "effect/http";
18import * as NetService from "@t3tools/shared/Net";
19import * as ServerConfig from "../config.ts";
20import * as ProcessRunner from "../processRunner.ts";
21import {
22 AGENT_DEVICE_VERSION,
23 DEVICE_HUB_VERSION,
24 agentDeviceStateDir,
25} from "./DeviceToolchain.ts";
26
27const processOutput = (code: number, stdout = "", stderr = "", timedOut = false) => ({
28 code: ChildProcessSpawner.ExitCode(code),
29 stdout,
30 stderr,
31 timedOut,
32 stdoutTruncated: false,
33 stderrTruncated: false,
34 stdoutInvalidUtf8: false,
35 stderrInvalidUtf8: false,
36});
37
38const agentFixture = Effect.fn("agentFixture")(function* (
39 startup: (
40 stateDir: string,
41 ) => Effect.Effect<ProcessRunner.ProcessRunOutput, ProcessRunner.ProcessRunError>,
42 avdOutput: ProcessRunner.ProcessRunOutput = processOutput(0),
43 previousDaemon?: typeof LocalDeviceHost.__testing.AgentDeviceDaemonFile.Type,
44) {
45 const fs = yield* FileSystem.FileSystem;
46 const path = yield* Path.Path;
47 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" });
48 const hub = { running: true, spawnFails: false };
49 const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata"));
50 const daemonHealthy = yield* Ref.make(true);
51 const runs: Array<ProcessRunner.ProcessRunInput> = [];
52 for (const [name, version, entry] of [
53 ["expo-device-hub", DEVICE_HUB_VERSION, "dist/server/cli.mjs"],
54 ["agent-device", AGENT_DEVICE_VERSION, "bin/agent-device.mjs"],
55 ]) {
56 const installDir = path.join(baseDir, "tools", name!, version!);
57 const entryPath = path.join(installDir, "node_modules", name!, entry!);
58 yield* fs.makeDirectory(path.dirname(entryPath), { recursive: true });
59 yield* fs.writeFileString(entryPath, "");
60 yield* fs.writeFileString(path.join(installDir, ".install-complete"), version!);
61 }
62 if (previousDaemon) {
63 yield* fs.makeDirectory(stateDir, { recursive: true });
64 yield* fs.writeFileString(path.join(stateDir, "daemon.json"), JSON.stringify(previousDaemon));
65 }
66 const host = yield* LocalDeviceHost.make().pipe(
67 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
68 Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }),
69 Effect.provideService(HostProcess.IsExecutable, false),
70 Effect.provideService(HostProcess.Platform, "darwin"),
71 Effect.provideService(
72 ChildProcessSpawner.ChildProcessSpawner,
73 ChildProcessSpawner.make(() =>
74 hub.spawnFails
75 ? Effect.fail(
76 PlatformError.systemError({
77 _tag: "NotFound",
78 module: "ChildProcess",
79 method: "spawn",
80 description: "Hub entry missing",
81 }),
82 )
83 : Effect.succeed(
84 ChildProcessSpawner.makeHandle({
85 pid: ChildProcessSpawner.ProcessId(123),
86 stdout: Stream.empty,
87 stderr: Stream.empty,
88 all: Stream.empty,
89 exitCode: Effect.never,
90 isRunning: Effect.sync(() => hub.running),
91 kill: () => Effect.void,
92 stdin: Sink.drain,
93 getInputFd: () => Sink.drain,
94 getOutputFd: () => Stream.empty,
95 unref: Effect.succeed(Effect.void),
96 }),
97 ),
98 ),
99 ),
100 Effect.provideService(ProcessRunner.ProcessRunner, {
101 run: (input) => {
102 runs.push(input);
103 return input.args.includes("devices")
104 ? startup(stateDir)
105 : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0));
106 },
107 }),
108 Effect.provideService(
109 HttpClient.HttpClient,
110 HttpClient.make((request) =>
111 Ref.get(daemonHealthy).pipe(
112 Effect.map((healthy) =>
113 HttpClientResponse.fromWeb(
114 request,
115 new Response("ok", {
116 status: request.url.endsWith("/health") && !healthy ? 503 : 200,
117 }),
118 ),
119 ),
120 ),
121 ),
122 ),
123 );
124 yield* host.ensureReady(() => Effect.void);
125 return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs };
126});
127
128describe("AgentDevice startup", () => {
129 it.effect("restarts a cached daemon that no longer answers its health check", () =>
130 Effect.gen(function* () {
131 const fs = yield* FileSystem.FileSystem;
132 let attempts = 0;
133 const { host, daemonHealthy } = yield* agentFixture((stateDir) =>
134 Effect.gen(function* () {
135 attempts++;
136 yield* fs
137 .writeFileString(
138 `${stateDir}/daemon.json`,
139 JSON.stringify({ httpPort: 12345 + attempts, token: `token-${attempts}` }),
140 )
141 .pipe(Effect.orDie);
142 return processOutput(0);
143 }),
144 );
145 const first = yield* host.ensureAgentReady(() => Effect.void);
146 expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual(
147 first.agentDevice,
148 );
149 expect(attempts).toBe(1);
150
151 yield* Ref.set(daemonHealthy, false);
152 const restarted = yield* host.ensureAgentReady(() => Effect.void);
153 expect(attempts).toBe(2);
154 expect(restarted.agentDevice).toMatchObject({
155 baseUrl: "http://127.0.0.1:12347",
156 token: "token-2",
157 });
158 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
159 );
160
161 it.effect("reports an incompatible lock immediately and retains it for manual repair", () =>
162 Effect.gen(function* () {
163 const result = processOutput(
164 1,
165 JSON.stringify({
166 success: false,
167 error: {
168 message: "private command output",
169 details: {
170 reason: "daemon_registration_unproven",
171 inspection: { state: "unproven", reason: "non-directory" },
172 },
173 },
174 }),
175 );
176 const { host, fs, stateDir } = yield* agentFixture(() => Effect.succeed(result));
177 const lock = `${stateDir}/daemon.lock`;
178 const legacyOwner = JSON.stringify({ pid: 85669, processStartTime: "old start time" });
179 yield* fs.writeFileString(lock, legacyOwner);
180 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
181 expect(error).toMatchObject({
182 _tag: "AgentDeviceStartError",
183 reason: "daemon_lock_not_directory",
184 exitCode: 1,
185 cause: result,
186 });
187 expect(error.message).toContain("Daemon registration ownership could not be verified");
188 expect(error.message).toContain("daemon.lock is not a directory");
189 expect(error.message).toContain(stateDir);
190 expect(error.message).toContain("original CLI");
191 expect(error.message).not.toContain("private command output");
192 expect(yield* fs.readFileString(lock)).toBe(legacyOwner);
193 expect(yield* host.current).not.toBeNull();
194 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
195 );
196
197 it.effect("reports unknown nonzero exits without exposing arbitrary command output", () =>
198 Effect.gen(function* () {
199 const result = processOutput(2, "invalid JSON", "private credentials");
200 const { host, stateDir } = yield* agentFixture(() => Effect.succeed(result));
201 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
202 expect(error).toMatchObject({
203 _tag: "AgentDeviceStartError",
204 reason: "command_failed",
205 exitCode: 2,
206 cause: result,
207 });
208 expect(error.message).toContain("exit code 2");
209 expect(error.message).toContain(stateDir);
210 expect(error.message).not.toContain("private credentials");
211 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
212 );
213
214 it.effect("preserves process execution failures instead of waiting for a daemon file", () =>
215 Effect.gen(function* () {
216 const cause = new ProcessRunner.ProcessSpawnError({
217 command: "/missing/node",
218 argumentCount: 3,
219 cause: new Error("missing executable"),
220 });
221 const { host } = yield* agentFixture(() => Effect.fail(cause));
222 const error = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.flip);
223 expect(error).toMatchObject({
224 _tag: "DeviceHostError",
225 step: "starting agent tools",
226 cause,
227 });
228 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
229 );
230
231 it.effect("uses a registered daemon when reading startup command output fails", () =>
232 Effect.gen(function* () {
233 const fs = yield* FileSystem.FileSystem;
234 const cause = new ProcessRunner.ProcessReadError({
235 command: process.execPath,
236 argumentCount: 3,
237 stream: "stdout",
238 cause: new Error("output read failed after registration"),
239 });
240 let attempts = 0;
241 const { host } = yield* agentFixture((stateDir) =>
242 Effect.gen(function* () {
243 attempts++;
244 yield* fs
245 .writeFileString(
246 `${stateDir}/daemon.json`,
247 JSON.stringify({ httpPort: 12345, token: "test-token" }),
248 )
249 .pipe(Effect.orDie);
250 return yield* cause;
251 }),
252 );
253 const ready = yield* host.ensureAgentReady(() => Effect.void);
254 expect(ready.agentDevice).toMatchObject({
255 baseUrl: "http://127.0.0.1:12345",
256 token: "test-token",
257 });
258 expect((yield* host.ensureAgentReady(() => Effect.void)).agentDevice).toEqual(
259 ready.agentDevice,
260 );
261 expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function");
262 expect(attempts).toBe(1);
263 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
264 );
265
266 it.effect("keeps releasing agent sessions while a dead hub fails to restart and after", () =>
267 Effect.gen(function* () {
268 const fs = yield* FileSystem.FileSystem;
269 const { host, hub } = yield* agentFixture((stateDir) =>
270 fs
271 .writeFileString(
272 `${stateDir}/daemon.json`,
273 JSON.stringify({ httpPort: 12345, token: "test-token" }),
274 )
275 .pipe(Effect.orDie, Effect.as(processOutput(0))),
276 );
277 yield* host.ensureAgentReady(() => Effect.void);
278 hub.running = false;
279 hub.spawnFails = true;
280 expect((yield* host.ensureReady(() => Effect.void).pipe(Effect.result))._tag).toBe("Failure");
281 expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function");
282 hub.spawnFails = false;
283 yield* host.ensureReady(() => Effect.void);
284 expect((yield* host.current)?.releaseAgentSession).toBeTypeOf("function");
285 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
286 );
287
288 it.effect("stops a previous server's daemon with the agent-device version it recorded", () =>
289 Effect.gen(function* () {
290 const path = yield* Path.Path;
291 const { host, fs, baseDir, runs } = yield* agentFixture(
292 () => Effect.die("A session that never readies agent tools must not start the daemon"),
293 processOutput(0),
294 { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" },
295 );
296 const olderInstall = path.join(baseDir, "tools", "agent-device", "0.0.1");
297 const olderEntry = path.join(
298 olderInstall,
299 "node_modules",
300 "agent-device",
301 "bin",
302 "agent-device.mjs",
303 );
304 yield* fs.makeDirectory(path.dirname(olderEntry), { recursive: true });
305 yield* fs.writeFileString(olderEntry, "");
306 yield* fs.writeFileString(path.join(olderInstall, ".install-complete"), "0.0.1");
307 yield* host.stop;
308 expect(runs.find((run) => run.args.includes("daemon"))?.args[0]).toBe(olderEntry);
309 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
310 );
311
312 // regression: #33552 a daemon left by a previous server survived every later graceful quit
313 it.effect("stops a previous server's daemon with the current pin when its version is gone", () =>
314 Effect.gen(function* () {
315 const path = yield* Path.Path;
316 const { host, baseDir, stateDir, runs } = yield* agentFixture(
317 () => Effect.die("A session that never readies agent tools must not start the daemon"),
318 processOutput(0),
319 { httpPort: 12345, token: "previous-token", pid: process.pid, version: "0.0.1" },
320 );
321 yield* host.stop;
322 expect(runs.find((run) => run.args.includes("daemon"))?.args).toEqual([
323 path.join(
324 baseDir,
325 "tools",
326 "agent-device",
327 AGENT_DEVICE_VERSION,
328 "node_modules",
329 "agent-device",
330 "bin",
331 "agent-device.mjs",
332 ),
333 "daemon",
334 "stop",
335 "--state-dir",
336 stateDir,
337 ]);
338 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
339 );
340
341 it.effect("drops a dead daemon's record without running agent-device when the host stops", () =>
342 Effect.gen(function* () {
343 // Above the Linux and macOS PID limits, so never a live process.
344 const deadPid = 4_194_305;
345 const path = yield* Path.Path;
346 const { host, fs, stateDir, runs } = yield* agentFixture(
347 () => Effect.die("A session that never readies agent tools must not start the daemon"),
348 processOutput(0),
349 { httpPort: 12345, token: "previous-token", pid: deadPid, version: AGENT_DEVICE_VERSION },
350 );
351 yield* host.stop;
352 expect(runs.filter((run) => run.args.includes("daemon"))).toEqual([]);
353 expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false);
354 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
355 );
356});
357
358const diagnose = (
359 files: ReadonlyArray<string>,
360 environment: NodeJS.ProcessEnv,
361 platform: NodeJS.Platform = "darwin",
362) =>
363 LocalDeviceHost.__testing.platformReason("android").pipe(
364 Effect.provideService(HostProcess.Environment, environment),
365 Effect.provideService(HostProcess.Platform, platform),
366 Effect.provideService(
367 FileSystem.FileSystem,
368 FileSystem.makeNoop({
369 exists: (file) => Effect.succeed(files.includes(file)),
370 }),
371 ),
372 Effect.provide(platform === "win32" ? NodePath.layerWin32 : NodePath.layerPosix),
373 );
374
375describe("Android SDK availability", () => {
376 it.effect("uses the standard Windows SDK without probing PATH", () =>
377 Effect.gen(function* () {
378 const root = "C:\\Users\\test\\AppData\\Local\\Android\\Sdk";
379 const probes: string[] = [];
380 const sdk = yield* LocalDeviceHost.__testing.androidSdk.pipe(
381 Effect.provideService(HostProcess.Environment, {
382 USERPROFILE: "C:\\Users\\test",
383 LOCALAPPDATA: "C:\\Users\\test\\AppData\\Local",
384 PATH: "\\\\slow-share\\tools;C:\\other-tools",
385 }),
386 Effect.provideService(HostProcess.Platform, "win32"),
387 Effect.provideService(
388 FileSystem.FileSystem,
389 FileSystem.makeNoop({
390 exists: (file) =>
391 Effect.sync(() => {
392 probes.push(file);
393 return file === `${root}\\platform-tools\\adb.exe`;
394 }),
395 realPath: () => Effect.die("Standard SDK must not resolve PATH executables"),
396 }),
397 ),
398 Effect.provide(NodePath.layerWin32),
399 );
400 expect(sdk).toMatchObject({ root, adb: true });
401 expect(probes.filter((file) => !file.startsWith("C:\\Users\\test\\"))).toEqual([]);
402 }),
403 );
404
405 it.effect("falls back to adb on the Windows Path without resolving missing entries", () =>
406 Effect.gen(function* () {
407 const root = "D:\\Android\\Sdk";
408 const resolved: string[] = [];
409 const sdk = yield* LocalDeviceHost.__testing.androidSdk.pipe(
410 Effect.provideService(HostProcess.Environment, {
411 USERPROFILE: "C:\\Users\\test",
412 Path: "C:\\missing;C:\\shims",
413 }),
414 Effect.provideService(HostProcess.Platform, "win32"),
415 Effect.provideService(
416 FileSystem.FileSystem,
417 FileSystem.makeNoop({
418 exists: (file) =>
419 Effect.succeed(
420 file === "C:\\shims\\adb.exe" || file === `${root}\\platform-tools\\adb.exe`,
421 ),
422 realPath: (file) =>
423 Effect.sync(() => {
424 resolved.push(file);
425 return `${root}\\platform-tools\\adb.exe`;
426 }),
427 }),
428 ),
429 Effect.provide(NodePath.layerWin32),
430 );
431 expect(sdk).toMatchObject({ root, adb: true });
432 expect(resolved).toEqual(["C:\\shims\\adb.exe"]);
433 }),
434 );
435
436 // regression: #12017 detect unsupported legacy Android command-line tools
437 it.effect("explains how to upgrade legacy command-line tools in the standard macOS SDK", () =>
438 Effect.gen(function* () {
439 const root = "/test/home/Library/Android/sdk";
440 const reason = yield* diagnose(
441 [`${root}/platform-tools/adb`, `${root}/emulator/emulator`, `${root}/tools/bin/avdmanager`],
442 { HOME: "/test/home" },
443 );
444 expect(reason).toContain("older, unsupported version");
445 expect(reason).toContain(root);
446 expect(reason).toContain(
447 "Install Android SDK Command-line Tools (latest) in Android Studio's SDK Manager under SDK Tools.",
448 );
449 }),
450 );
451
452 it.effect("accepts the latest command-line tools when legacy tools are also installed", () =>
453 Effect.gen(function* () {
454 const reason = yield* diagnose(
455 [
456 "/sdk/platform-tools/adb",
457 "/sdk/emulator/emulator",
458 "/sdk/tools/bin/avdmanager",
459 "/sdk/cmdline-tools/latest/bin/avdmanager",
460 ],
461 { ANDROID_HOME: "/sdk" },
462 );
463 expect(reason).toBeNull();
464 }),
465 );
466
467 it.effect("discovers the standard macOS SDK without ANDROID_HOME", () =>
468 Effect.gen(function* () {
469 const root = "/test/home/Library/Android/sdk";
470 const reason = yield* diagnose(
471 [
472 `${root}/platform-tools/adb`,
473 `${root}/emulator/emulator`,
474 `${root}/cmdline-tools/latest/bin/avdmanager`,
475 ],
476 { HOME: "/test/home" },
477 );
478 expect(reason).toBeNull();
479 }),
480 );
481});
482
483it.effect(
484 "recovers an owned Linux runtime directory without overriding explicit environments",
485 () =>
486 Effect.gen(function* () {
487 const fs = yield* FileSystem.FileSystem;
488 const directory = yield* fs.makeTempDirectoryScoped();
489 const stat = yield* fs.stat(directory);
490 for (const [platform, uid, runtimeDir, type, owner, expected] of [
491 ["linux", 1000, undefined, "Directory", 1000, "/run/user/1000"],
492 ["linux", 1000, "/custom/runtime", "Directory", 1000, "/custom/runtime"],
493 ["linux", 1000, "", "Directory", 1000, ""],
494 ["linux", 1000, undefined, "Directory", 1001, undefined],
495 ["linux", 1000, undefined, "File", 1000, undefined],
496 ["linux", undefined, undefined, "Directory", 1000, undefined],
497 ["darwin", 1000, undefined, "Directory", 1000, undefined],
498 ["win32", 1000, undefined, "Directory", 1000, undefined],
499 ] as const) {
500 const environment = { PATH: "/usr/bin", XDG_RUNTIME_DIR: runtimeDir };
501 const result = yield* LocalDeviceHost.__testing.hubEnvironment(environment).pipe(
502 Effect.provideService(HostProcess.Platform, platform),
503 Effect.provideService(HostProcess.UserId, uid),
504 Effect.provideService(
505 FileSystem.FileSystem,
506 FileSystem.makeNoop({
507 stat: (path) => {
508 expect(path).toBe("/run/user/1000");
509 return Effect.succeed({ ...stat, type, uid: Option.some(owner) });
510 },
511 }),
512 ),
513 );
514 expect(result.XDG_RUNTIME_DIR).toBe(expected);
515 expect(result.PATH).toBe("/usr/bin");
516 expect(result.FORCE_COLOR).toBe("0");
517 expect(result.NO_COLOR).toBe("1");
518 expect(environment.XDG_RUNTIME_DIR).toBe(runtimeDir);
519 }
520 const missing = yield* LocalDeviceHost.__testing
521 .hubEnvironment({})
522 .pipe(
523 Effect.provideService(HostProcess.Platform, "linux"),
524 Effect.provideService(HostProcess.UserId, 1000),
525 Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
526 );
527 expect(missing.XDG_RUNTIME_DIR).toBeUndefined();
528 }).pipe(
529 Effect.scoped,
530 Effect.provide(Layer.provideMerge(ProcessRunner.layer, NodeServices.layer)),
531 ),
532);
533
534it.effect.each([
535 {
536 name: "old SDK",
537 front: "emulated webcam<N> none",
538 back: "emulated virtualscene none",
539 code: 0,
540 extra: undefined,
541 expected: "-camera-front emulated -camera-back emulated",
542 },
543 {
544 name: "imagefile-capable SDK",
545 front: "emulated imagefile:<path> none",
546 back: "emulated imagefile:<path> none",
547 code: 0,
548 extra: undefined,
549 expected: undefined,
550 },
551 {
552 name: "user overrides",
553 front: "emulated none",
554 back: "emulated none",
555 code: 0,
556 extra: "-memory 4096 -camera-front none",
557 expected: "-camera-front emulated -camera-back emulated -memory 4096 -camera-front none",
558 },
559 {
560 name: "different camera capabilities",
561 front: "emulated none",
562 back: "emulated imagefile:<path> none",
563 code: 0,
564 extra: undefined,
565 expected: "-camera-front emulated",
566 },
567 {
568 name: "failed help probe",
569 front: "emulated none",
570 back: "emulated none",
571 code: 1,
572 extra: undefined,
573 expected: undefined,
574 },
575])("configures supported emulator cameras for $name", ({ front, back, code, extra, expected }) =>
576 Effect.gen(function* () {
577 const result = yield* LocalDeviceHost.__testing
578 .hubEnvironment({ ANDROID_HOME: "/sdk", EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: extra })
579 .pipe(
580 Effect.provideService(HostProcess.Platform, "darwin"),
581 Effect.provideService(HostProcess.UserId, 1000),
582 Effect.provideService(ProcessRunner.ProcessRunner, {
583 run: (input) => {
584 expect(input.command).toBe("/sdk/emulator/emulator");
585 expect(input.args[0]).toMatch(/^-help-camera-(front|back)$/);
586 return Effect.succeed(
587 processOutput(code, input.args[0] === "-help-camera-front" ? front : back),
588 );
589 },
590 }),
591 Effect.provide(NodeServices.layer),
592 );
593 expect(result.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS).toBe(expected);
594 }),
595);
596
597it.effect("keeps local Android and iOS available without ffmpeg", () =>
598 Effect.gen(function* () {
599 const fs = yield* FileSystem.FileSystem;
600 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-ffmpeg-" });
601 yield* fs.makeDirectory(`${baseDir}/bin`);
602 yield* fs.writeFileString(`${baseDir}/bin/xcrun`, "#!/bin/sh\nexit 0\n");
603 yield* fs.chmod(`${baseDir}/bin/xcrun`, 0o755);
604 for (const [directory, tool] of [
605 ["platform-tools", "adb"],
606 ["emulator", "emulator"],
607 ["cmdline-tools/latest/bin", "avdmanager"],
608 ]) {
609 yield* fs.makeDirectory(`${baseDir}/sdk/${directory}`, { recursive: true });
610 yield* fs.writeFileString(`${baseDir}/sdk/${directory}/${tool}`, "");
611 }
612 const onTestHost = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
613 effect.pipe(
614 Effect.provideService(HostProcess.Environment, {
615 HOME: baseDir,
616 PATH: `${baseDir}/bin`,
617 ANDROID_HOME: `${baseDir}/sdk`,
618 }),
619 Effect.provideService(HostProcess.Platform, "darwin"),
620 );
621 const host = yield* LocalDeviceHost.make().pipe(
622 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
623 onTestHost,
624 Effect.provideService(
625 HttpClient.HttpClient,
626 HttpClient.make(() => Effect.die(new Error("Readiness must not make network requests"))),
627 ),
628 Effect.provideService(ProcessRunner.ProcessRunner, {
629 run: () => Effect.die("Platform availability must not probe emulator video"),
630 }),
631 );
632 expect((yield* onTestHost(host.summary)).platforms).toEqual([
633 { platform: "ios", available: true },
634 { platform: "android", available: true },
635 ]);
636 expect(yield* onTestHost(host.platformAvailability("android"))).toEqual({
637 platform: "android",
638 available: true,
639 });
640 expect(yield* onTestHost(host.platformAvailability("ios"))).toEqual({
641 platform: "ios",
642 available: true,
643 });
644 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
645);
646
647it.effect(
648 "constructs and inspects an unconfigured host without installing or starting helpers",
649 () =>
650 Effect.gen(function* () {
651 const fs = yield* FileSystem.FileSystem;
652 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-consent-" });
653 const host = yield* LocalDeviceHost.make().pipe(
654 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(baseDir, baseDir), NetService.layer)),
655 Effect.provideService(HostProcess.Environment, { HOME: baseDir, PATH: "" }),
656 Effect.provideService(HostProcess.Platform, "linux"),
657 Effect.provideService(
658 ChildProcessSpawner.ChildProcessSpawner,
659 ChildProcessSpawner.make(() =>
660 Effect.die(new Error("Host construction must not spawn processes")),
661 ),
662 ),
663 Effect.provideService(ProcessRunner.ProcessRunner, {
664 run: () => Effect.die(new Error("Host construction must not run commands")),
665 }),
666 Effect.provideService(
667 HttpClient.HttpClient,
668 HttpClient.make(() =>
669 Effect.die(new Error("Host construction must not make network requests")),
670 ),
671 ),
672 );
673 expect(yield* host.current).toBeNull();
674 const error = yield* host
675 .ensureReady(() => Effect.die("Must not install without Node"))
676 .pipe(Effect.flip, Effect.provideService(HostProcess.IsExecutable, true));
677 expect(error.message).toContain("Local device support requires Node.js");
678 expect(error.message).toContain("Install Node.js");
679 yield* host.stop;
680 expect(yield* fs.exists(`${baseDir}/tools`)).toBe(false);
681 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
682);