1import { expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Net from "@t3tools/shared/Net";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientResponse from "effect/http/HttpClientResponse";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13import * as ServerConfig from "../config.ts";
14import * as DeviceHost from "./DeviceHost.ts";
15import * as SshDeviceHost from "./SshDeviceHost.ts";
16
17it.effect("preserves installed status after probes and cleans failed agent activation", () =>
18 Effect.gen(function* () {
19 const fs = yield* FileSystem.FileSystem;
20 const home = yield* fs.makeTempDirectoryScoped();
21 const modes: string[] = [];
22 const owners: string[] = [];
23 let forwards = 0;
24 let failForward = true;
25 let rejectConfig = true;
26 const spawner = ChildProcessSpawner.make((command) =>
27 Effect.gen(function* () {
28 if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command");
29 const forwarding = command.args.includes("-N");
30 let output = "";
31 if (forwarding) {
32 if (failForward) {
33 failForward = false;
34 return yield* PlatformError.systemError({
35 _tag: "AlreadyExists",
36 module: "ChildProcess",
37 method: "spawn",
38 description: "Port already bound",
39 });
40 }
41 forwards++;
42 yield* Effect.addFinalizer(() =>
43 Effect.sync(() => {
44 forwards--;
45 }),
46 );
47 } else {
48 const stdin = command.options.stdin;
49 if (
50 !stdin ||
51 typeof stdin !== "object" ||
52 !("stream" in stdin) ||
53 !Stream.isStream(stdin.stream)
54 )
55 return yield* Effect.die("Missing script");
56 const script = yield* stdin.stream.pipe(
57 Stream.decodeText(),
58 Stream.runFold(
59 () => "",
60 (a, b) => a + b,
61 ),
62 );
63 const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? "";
64 modes.push(mode);
65 owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? "");
66 output = JSON.stringify({
67 nodePath: "/node",
68 platforms: [{ platform: "ios", available: true }],
69 hubPort: 1234,
70 helpers: { serveSimAxSettings: null, serveSimCli: null },
71 ...(mode === "agent-start"
72 ? { daemonPort: 1235, token: "fixture", entryPath: "/agent.mjs" }
73 : {}),
74 });
75 }
76 return ChildProcessSpawner.makeHandle({
77 pid: ChildProcessSpawner.ProcessId(123),
78 stdout: Stream.make(new TextEncoder().encode(output)),
79 stderr: Stream.empty,
80 all: Stream.empty,
81 exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)),
82 isRunning: Effect.succeed(forwarding),
83 kill: () => Effect.void,
84 stdin: Sink.drain,
85 getInputFd: () => Sink.drain,
86 getOutputFd: () => Stream.empty,
87 unref: Effect.succeed(Effect.void),
88 });
89 }),
90 );
91 const host = yield* SshDeviceHost.make(
92 { id: "test", label: "Test", target: "test.example" },
93 () =>
94 rejectConfig
95 ? Effect.fail(
96 new DeviceHost.DeviceHostError({
97 hostId: "test",
98 step: "configuring agent access",
99 cause: new Error("fixture failure"),
100 }),
101 )
102 : Effect.void,
103 ).pipe(
104 Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)),
105 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
106 Effect.provideService(
107 HttpClient.HttpClient,
108 HttpClient.make((request) =>
109 Effect.succeed(HttpClientResponse.fromWeb(request, new Response("ok"))),
110 ),
111 ),
112 );
113 yield* host.ensureReady(() => Effect.void);
114 yield* SshDeviceHost.probe({ id: "test", label: "Test", target: "test.example" }).pipe(
115 Effect.provide(ServerConfig.layerTest(home, home)),
116 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
117 );
118 expect(new Set(owners).size).toBe(1);
119 expect(owners[0]).toMatch(/^[a-f0-9]{24}$/);
120 expect(forwards).toBe(1);
121 expect(modes.filter((mode) => mode === "start")).toHaveLength(2);
122 yield* host.platformAvailability("ios");
123 expect((yield* host.summary).hubInstalled).toBe(true);
124 const failed = yield* host.ensureAgentReady(() => Effect.void).pipe(Effect.result);
125 expect(failed._tag).toBe("Failure");
126 expect(forwards).toBe(0);
127 expect(modes.at(-1)).toBe("stop-agent");
128 expect(yield* host.current).toBeNull();
129 rejectConfig = false;
130 yield* host.ensureAgentReady(() => Effect.void);
131 yield* host.platformAvailability("ios");
132 expect((yield* host.summary).agentDeviceInstalled).toBe(true);
133 yield* host.stopAgent;
134 expect(forwards).toBe(1);
135 yield* host.stop;
136 expect(forwards).toBe(0);
137 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
138);