| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Net from "@t3tools/shared/Net"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as PlatformError from "effect/PlatformError"; |
| 8 | import * as Sink from "effect/Sink"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import * as HttpClient from "effect/http/HttpClient"; |
| 11 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 12 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 13 | import * as ServerConfig from "../config.ts"; |
| 14 | import * as DeviceHost from "./DeviceHost.ts"; |
| 15 | import * as SshDeviceHost from "./SshDeviceHost.ts"; |
| 16 | |
| 17 | it.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 | ); |