| 1 | import { expect, it } from "@effect/vitest"; |
| 2 | import { ThreadId } from "@t3tools/contracts"; |
| 3 | import * as Deferred from "effect/Deferred"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 8 | import * as ServerSettings from "../serverSettings.ts"; |
| 9 | import * as DeviceHost from "./DeviceHost.ts"; |
| 10 | import * as DeviceService from "./DeviceService.ts"; |
| 11 | |
| 12 | it.effect("keeps hosts independent when serials collide and another host fails", () => |
| 13 | Effect.gen(function* () { |
| 14 | const host = (id: string, failed = false): DeviceHost.DeviceHost["Service"] => { |
| 15 | const ready = { |
| 16 | nodePath: process.execPath, |
| 17 | hub: { origin: `http://${id}` }, |
| 18 | agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, |
| 19 | run: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), |
| 20 | helpers: { serveSimAxSettings: null, serveSimCli: null }, |
| 21 | }; |
| 22 | return { |
| 23 | id, |
| 24 | summary: Effect.succeed({ |
| 25 | id, |
| 26 | label: id, |
| 27 | kind: id === "b" ? "ssh" : "local", |
| 28 | hubInstalled: true, |
| 29 | agentDeviceInstalled: true, |
| 30 | platforms: id === "b" ? [] : [{ platform: "android", available: true }], |
| 31 | }), |
| 32 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 33 | ensureReady: () => |
| 34 | failed |
| 35 | ? Effect.fail( |
| 36 | new DeviceHost.DeviceHostError({ |
| 37 | hostId: id, |
| 38 | step: "connect", |
| 39 | cause: new Error("offline"), |
| 40 | }), |
| 41 | ) |
| 42 | : Effect.succeed(ready), |
| 43 | ensureAgentReady: () => Effect.succeed(ready), |
| 44 | current: Effect.succeed(ready), |
| 45 | stopAgent: Effect.void, |
| 46 | stop: Effect.void, |
| 47 | }; |
| 48 | }; |
| 49 | const http = HttpClient.make((request) => |
| 50 | Effect.succeed( |
| 51 | HttpClientResponse.fromWeb( |
| 52 | request, |
| 53 | Response.json({ |
| 54 | simulators: [], |
| 55 | emulators: [ |
| 56 | { |
| 57 | id: "emulator-5554", |
| 58 | name: "Pixel", |
| 59 | version: "36", |
| 60 | platform: "android", |
| 61 | booted: true, |
| 62 | physical: false, |
| 63 | }, |
| 64 | ], |
| 65 | }), |
| 66 | ), |
| 67 | ), |
| 68 | ); |
| 69 | const hosts = new Map(["a", "b", "offline"].map((id) => [id, host(id, id === "offline")])); |
| 70 | const writeStarted = yield* Deferred.make<void>(); |
| 71 | const finishWrite = yield* Deferred.make<void>(); |
| 72 | const order: string[] = []; |
| 73 | const service = yield* DeviceService.makeWithHosts(hosts, undefined, () => |
| 74 | Effect.gen(function* () { |
| 75 | order.push("write started"); |
| 76 | yield* Deferred.succeed(writeStarted, undefined); |
| 77 | yield* Deferred.await(finishWrite); |
| 78 | order.push("write finished"); |
| 79 | return "/host-config.json"; |
| 80 | }), |
| 81 | ).pipe(Effect.provide(NodeCrypto.layer), Effect.provideService(HttpClient.HttpClient, http)); |
| 82 | expect(yield* service.agentReadinessIfSupported("b")).not.toBeNull(); |
| 83 | const listed = yield* service.list; |
| 84 | expect(listed.devices.map((device) => device.hostId).sort()).toEqual(["a", "b"]); |
| 85 | expect(listed.hostStatuses.offline?.status).toBe("failed"); |
| 86 | const threadId = ThreadId.make("thread"); |
| 87 | for (const hostId of ["a", "b"]) |
| 88 | yield* service.open({ threadId, hostId, deviceId: "emulator-5554", platform: "android" }); |
| 89 | yield* service.close({ threadId, hostId: "a", deviceId: "emulator-5554" }); |
| 90 | const state = yield* service.state; |
| 91 | expect(state.devices).toHaveLength(2); |
| 92 | expect(state.sessions.map((session) => session.hostId)).toEqual(["b"]); |
| 93 | expect(state.hostStatuses.a?.status).toBe("ready"); |
| 94 | expect(state.hostStatuses.offline?.status).toBe("failed"); |
| 95 | const targeting = yield* service |
| 96 | .agentTarget({ threadId, hostId: "b", deviceId: "emulator-5554" }) |
| 97 | .pipe(Effect.forkChild); |
| 98 | yield* Deferred.await(writeStarted); |
| 99 | const replacing = yield* service |
| 100 | .withLifecycleLock( |
| 101 | Effect.gen(function* () { |
| 102 | order.push("replace"); |
| 103 | hosts.set("b", host("b")); |
| 104 | yield* service.refreshHosts; |
| 105 | }), |
| 106 | ) |
| 107 | .pipe(Effect.forkChild); |
| 108 | yield* Deferred.succeed(finishWrite, undefined); |
| 109 | yield* Fiber.join(targeting); |
| 110 | yield* Fiber.join(replacing); |
| 111 | expect(order).toEqual(["write started", "write finished", "replace"]); |
| 112 | const replaced = yield* service.state; |
| 113 | expect(replaced.sessions).toEqual([]); |
| 114 | expect(replaced.devices.map((device) => device.hostId)).toEqual(["a"]); |
| 115 | expect(replaced.hostStatuses.b).toBeUndefined(); |
| 116 | yield* service.open({ threadId, hostId: "b", deviceId: "emulator-5554", platform: "android" }); |
| 117 | hosts.delete("b"); |
| 118 | yield* service.refreshHosts; |
| 119 | yield* service.setHostStatus("b", { status: "ready" }); |
| 120 | expect((yield* service.state).hostStatuses.b).toBeUndefined(); |
| 121 | expect((yield* service.state).sessions).toEqual([]); |
| 122 | yield* service.agentReadinessIfSupported("a"); |
| 123 | expect((yield* service.state).hostStatuses.a?.status).toBe("ready"); |
| 124 | yield* service.configure({ enabled: false }); |
| 125 | expect((yield* service.state).hostStatuses).toEqual({}); |
| 126 | }).pipe( |
| 127 | Effect.provide( |
| 128 | ServerSettings.layerTest({ |
| 129 | enableDeviceSupport: true, |
| 130 | enableAgentDeviceAccess: true, |
| 131 | }), |
| 132 | ), |
| 133 | ), |
| 134 | ); |