| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { |
| 3 | DEFAULT_SERVER_SETTINGS, |
| 4 | DeviceId, |
| 5 | DeviceOperationError, |
| 6 | LOCAL_DEVICE_HOST_ID, |
| 7 | ThreadId, |
| 8 | type DeviceServiceState, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Exit from "effect/Exit"; |
| 13 | import * as Deferred from "effect/Deferred"; |
| 14 | import * as Fiber from "effect/Fiber"; |
| 15 | import * as PubSub from "effect/PubSub"; |
| 16 | import * as Ref from "effect/Ref"; |
| 17 | import * as Schema from "effect/Schema"; |
| 18 | import * as Stream from "effect/Stream"; |
| 19 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 20 | import * as ServerSettings from "../serverSettings.ts"; |
| 21 | import * as DeviceHost from "./DeviceHost.ts"; |
| 22 | import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; |
| 23 | |
| 24 | import * as DeviceService from "./DeviceService.ts"; |
| 25 | |
| 26 | const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); |
| 27 | |
| 28 | const baseState: DeviceServiceState = { |
| 29 | hosts: [], |
| 30 | hostStatus: "idle", |
| 31 | hostStatuses: {}, |
| 32 | devices: [], |
| 33 | sessions: [], |
| 34 | onboardingCompleted: false, |
| 35 | agentAccessEnabled: false, |
| 36 | hubBasePath: "/api/device-hub", |
| 37 | revision: 0, |
| 38 | }; |
| 39 | |
| 40 | describe("DeviceService.stateStream", () => { |
| 41 | it.effect("emits the current snapshot and then every published change", () => |
| 42 | Effect.gen(function* () { |
| 43 | const pubsub = yield* PubSub.unbounded<DeviceServiceState>(); |
| 44 | const current = yield* Ref.make(baseState); |
| 45 | const service: Pick<DeviceService.DeviceService["Service"], "state" | "subscribe"> = { |
| 46 | state: Ref.get(current), |
| 47 | subscribe: PubSub.subscribe(pubsub), |
| 48 | }; |
| 49 | |
| 50 | const collected = yield* DeviceService.stateStream( |
| 51 | service as DeviceService.DeviceService["Service"], |
| 52 | ).pipe(Stream.take(3), Stream.runCollect, Effect.forkChild); |
| 53 | yield* Effect.yieldNow; |
| 54 | for (const revision of [1, 2]) { |
| 55 | const next = { ...baseState, revision, hostStatus: "ready" as const }; |
| 56 | yield* Ref.set(current, next); |
| 57 | yield* PubSub.publish(pubsub, next); |
| 58 | } |
| 59 | const seen = yield* Fiber.join(collected); |
| 60 | expect(seen.map((state) => state.revision)).toEqual([0, 1, 2]); |
| 61 | }), |
| 62 | ); |
| 63 | }); |
| 64 | |
| 65 | const fixture = Effect.fn("fixture")(function* ( |
| 66 | onBoot: Effect.Effect<void> = Effect.void, |
| 67 | bootError?: string, |
| 68 | failListAfterShutdown = false, |
| 69 | runtimeFailure?: |
| 70 | | NodeRuntimeUnavailableError |
| 71 | | DeviceHost.DeviceHostError |
| 72 | | DeviceHost.AgentDeviceStartError, |
| 73 | inspectError = false, |
| 74 | installTool?: Parameters<typeof DeviceService.makeWithHosts>[3], |
| 75 | ) { |
| 76 | const settings = yield* Ref.make(DEFAULT_SERVER_SETTINGS); |
| 77 | const starts: string[] = []; |
| 78 | const agentStarts: string[] = []; |
| 79 | const agentStops: string[] = []; |
| 80 | const requests: string[] = []; |
| 81 | let booted = false; |
| 82 | let shutDown = false; |
| 83 | const ready: DeviceHost.DeviceHostReady = { |
| 84 | nodePath: process.execPath, |
| 85 | hub: { origin: "http://device.test" }, |
| 86 | helpers: { serveSimAxSettings: null, serveSimCli: null }, |
| 87 | run: () => Effect.succeed({ code: 0, stdout: "Pixel_API_35\n", stderr: "" }), |
| 88 | }; |
| 89 | const host: DeviceHost.DeviceHost["Service"] = { |
| 90 | ...(inspectError |
| 91 | ? { |
| 92 | inspect: Effect.fail( |
| 93 | new DeviceHost.DeviceHostError({ |
| 94 | hostId: LOCAL_DEVICE_HOST_ID, |
| 95 | step: "probe", |
| 96 | cause: new Error("offline"), |
| 97 | }), |
| 98 | ), |
| 99 | } |
| 100 | : {}), |
| 101 | id: LOCAL_DEVICE_HOST_ID, |
| 102 | summary: Effect.succeed({ |
| 103 | id: LOCAL_DEVICE_HOST_ID, |
| 104 | kind: "local", |
| 105 | label: "Test server", |
| 106 | platforms: [{ platform: "android", available: true }], |
| 107 | hubInstalled: true, |
| 108 | agentDeviceInstalled: false, |
| 109 | }), |
| 110 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 111 | ensureReady: (onPhase) => |
| 112 | Effect.gen(function* () { |
| 113 | if (runtimeFailure && runtimeFailure._tag !== "AgentDeviceStartError") |
| 114 | return yield* runtimeFailure; |
| 115 | starts.push("start"); |
| 116 | yield* onPhase("installing", "Updating device hub from 0.9.0 to 0.10.1…"); |
| 117 | return ready; |
| 118 | }), |
| 119 | ensureAgentReady: (onPhase) => |
| 120 | Effect.gen(function* () { |
| 121 | if (runtimeFailure) return yield* runtimeFailure; |
| 122 | agentStarts.push("start"); |
| 123 | yield* onPhase("starting"); |
| 124 | return { |
| 125 | ...ready, |
| 126 | agentDevice: { baseUrl: "http://agent.test", token: "test", entryPath: "/agent" }, |
| 127 | }; |
| 128 | }), |
| 129 | current: Effect.succeed(null), |
| 130 | stopAgent: Effect.sync(() => { |
| 131 | agentStops.push("stop"); |
| 132 | }), |
| 133 | stop: Effect.sync(() => { |
| 134 | starts.push("stop"); |
| 135 | }), |
| 136 | }; |
| 137 | const service = yield* DeviceService.makeWithHosts( |
| 138 | new Map([[host.id, host]]), |
| 139 | undefined, |
| 140 | undefined, |
| 141 | installTool, |
| 142 | ).pipe( |
| 143 | Effect.provide(NodeCrypto.layer), |
| 144 | Effect.provideService(DeviceHost.DeviceHost, host), |
| 145 | Effect.provideService( |
| 146 | ServerSettings.ServerSettingsService, |
| 147 | ServerSettings.ServerSettingsService.of({ |
| 148 | updateProviderInstance: () => Effect.die("Unexpected provider mutation"), |
| 149 | withSettingsSnapshot: (use) => Effect.flatMap(Ref.get(settings), use), |
| 150 | start: Effect.void, |
| 151 | ready: Effect.void, |
| 152 | getSettings: Ref.get(settings), |
| 153 | updateSettings: (patch) => |
| 154 | Ref.updateAndGet(settings, (current) => ({ |
| 155 | ...current, |
| 156 | enableDeviceSupport: patch.enableDeviceSupport ?? current.enableDeviceSupport, |
| 157 | enableAgentDeviceAccess: |
| 158 | patch.enableAgentDeviceAccess ?? current.enableAgentDeviceAccess, |
| 159 | deviceOnboardingCompleted: |
| 160 | patch.deviceOnboardingCompleted ?? current.deviceOnboardingCompleted, |
| 161 | })), |
| 162 | streamChanges: Stream.empty, |
| 163 | subscribeChanges: Effect.succeed(Stream.empty), |
| 164 | }), |
| 165 | ), |
| 166 | Effect.provideService( |
| 167 | HttpClient.HttpClient, |
| 168 | HttpClient.make((request) => |
| 169 | Effect.gen(function* () { |
| 170 | requests.push(request.url); |
| 171 | if (request.url.includes("/api/screenshot")) { |
| 172 | return HttpClientResponse.fromWeb( |
| 173 | request, |
| 174 | new Response(new Uint8Array([137, 80, 78, 71])), |
| 175 | ); |
| 176 | } |
| 177 | if (request.url.endsWith("/shutdown")) { |
| 178 | shutDown = true; |
| 179 | booted = false; |
| 180 | return HttpClientResponse.fromWeb(request, Response.json({ ok: true })); |
| 181 | } |
| 182 | if (shutDown && failListAfterShutdown) { |
| 183 | return HttpClientResponse.fromWeb( |
| 184 | request, |
| 185 | new Response("Discovery busy", { status: 503 }), |
| 186 | ); |
| 187 | } |
| 188 | if (request.url.endsWith("/boot")) { |
| 189 | yield* onBoot; |
| 190 | booted = true; |
| 191 | return HttpClientResponse.fromWeb( |
| 192 | request, |
| 193 | Response.json( |
| 194 | bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" }, |
| 195 | ), |
| 196 | ); |
| 197 | } |
| 198 | return HttpClientResponse.fromWeb( |
| 199 | request, |
| 200 | Response.json({ |
| 201 | simulators: [], |
| 202 | emulators: booted |
| 203 | ? [ |
| 204 | { |
| 205 | id: "emulator-5554", |
| 206 | name: "Pixel_API_35", |
| 207 | platform: "android", |
| 208 | version: "Android 15", |
| 209 | booted: true, |
| 210 | physical: false, |
| 211 | }, |
| 212 | ] |
| 213 | : [], |
| 214 | }), |
| 215 | ); |
| 216 | }), |
| 217 | ), |
| 218 | ), |
| 219 | ); |
| 220 | return { service, starts, agentStarts, agentStops, requests, settings }; |
| 221 | }); |
| 222 | |
| 223 | describe("device setup consent", () => { |
| 224 | it.effect("exposes daemon startup diagnostics through agent readiness and host status", () => |
| 225 | Effect.gen(function* () { |
| 226 | const startupError = new DeviceHost.AgentDeviceStartError({ |
| 227 | hostId: LOCAL_DEVICE_HOST_ID, |
| 228 | stateDir: "/test/device/agent-device", |
| 229 | exitCode: 1, |
| 230 | reason: "daemon_lock_not_directory", |
| 231 | cause: { stdout: "private startup diagnostics", code: 1 }, |
| 232 | }); |
| 233 | const { service, settings } = yield* fixture(Effect.void, undefined, false, startupError); |
| 234 | yield* Ref.update(settings, (current) => ({ |
| 235 | ...current, |
| 236 | enableDeviceSupport: true, |
| 237 | enableAgentDeviceAccess: true, |
| 238 | })); |
| 239 | const error = yield* service.agentReadinessIfSupported().pipe(Effect.flip); |
| 240 | expect(error).toMatchObject({ |
| 241 | _tag: "DeviceHostUnavailableError", |
| 242 | reason: startupError.message, |
| 243 | cause: startupError, |
| 244 | }); |
| 245 | expect(error.message).toContain("daemon.lock is not a directory"); |
| 246 | expect(error.message).not.toContain("private startup diagnostics"); |
| 247 | expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ |
| 248 | status: "failed", |
| 249 | detail: startupError.message, |
| 250 | }); |
| 251 | }).pipe(Effect.scoped), |
| 252 | ); |
| 253 | |
| 254 | it.effect( |
| 255 | "preserves missing-runtime guidance and causes through manual and agent readiness", |
| 256 | () => |
| 257 | Effect.gen(function* () { |
| 258 | const underlying = new Error("private lookup diagnostics"); |
| 259 | const runtimeFailure = new NodeRuntimeUnavailableError({ |
| 260 | feature: "Local device support", |
| 261 | cause: underlying, |
| 262 | }); |
| 263 | const { service, settings, requests } = yield* fixture( |
| 264 | Effect.void, |
| 265 | undefined, |
| 266 | false, |
| 267 | runtimeFailure, |
| 268 | ); |
| 269 | yield* Ref.update(settings, (current) => ({ |
| 270 | ...current, |
| 271 | enableDeviceSupport: true, |
| 272 | enableAgentDeviceAccess: true, |
| 273 | })); |
| 274 | for (const readiness of [service.readiness(), service.agentReadinessIfSupported()]) { |
| 275 | const error = yield* readiness.pipe(Effect.flip); |
| 276 | expect(error).toMatchObject({ |
| 277 | _tag: "DeviceHostUnavailableError", |
| 278 | reason: expect.stringContaining("Install Node.js"), |
| 279 | cause: runtimeFailure, |
| 280 | }); |
| 281 | expect(error.message).not.toContain(underlying.message); |
| 282 | } |
| 283 | expect((yield* service.state).hostStatuses[LOCAL_DEVICE_HOST_ID]).toMatchObject({ |
| 284 | status: "failed", |
| 285 | detail: expect.stringContaining("Install Node.js"), |
| 286 | }); |
| 287 | expect(requests).toEqual([]); |
| 288 | }).pipe(Effect.scoped), |
| 289 | ); |
| 290 | |
| 291 | it.effect("listing and provider startup do not start helpers before consent", () => |
| 292 | Effect.gen(function* () { |
| 293 | const { service, starts, requests } = yield* fixture(); |
| 294 | expect((yield* service.list).hostStatus).toBe("disabled"); |
| 295 | expect(yield* service.readinessIfSupported()).toBeNull(); |
| 296 | const readiness = yield* service.readiness().pipe(Effect.result); |
| 297 | expect(readiness._tag).toBe("Failure"); |
| 298 | expect(starts).toEqual([]); |
| 299 | expect(requests).toEqual([]); |
| 300 | }).pipe(Effect.scoped), |
| 301 | ); |
| 302 | |
| 303 | it.effect( |
| 304 | "explicit setup discovers never-booted AVDs; disabling stops helpers and blocks agents", |
| 305 | () => |
| 306 | Effect.gen(function* () { |
| 307 | const { service, starts, settings } = yield* fixture(); |
| 308 | const state = yield* service.configure({ enabled: true }); |
| 309 | expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(true); |
| 310 | expect(state.devices.map((device) => [device.id, device.booted])).toEqual([ |
| 311 | ["Pixel_API_35", false], |
| 312 | ]); |
| 313 | expect(starts).toEqual(["start"]); |
| 314 | const disabled = yield* service.configure({ enabled: false }); |
| 315 | expect(disabled.hostStatus).toBe("disabled"); |
| 316 | expect(disabled.devices).toEqual([]); |
| 317 | expect((yield* Ref.get(settings)).enableDeviceSupport).toBe(false); |
| 318 | expect(yield* service.readinessIfSupported()).toBeNull(); |
| 319 | expect(starts).toEqual(["start", "stop"]); |
| 320 | }).pipe(Effect.scoped), |
| 321 | ); |
| 322 | |
| 323 | it.effect("boots a stopped Android AVD and uses its emulator serial without duplicating it", () => |
| 324 | Effect.gen(function* () { |
| 325 | const { service, requests } = yield* fixture(); |
| 326 | yield* service.configure({ enabled: true }); |
| 327 | const session = yield* service.open({ |
| 328 | threadId: ThreadId.make("thread-1"), |
| 329 | deviceId: "Pixel_API_35", |
| 330 | platform: "android", |
| 331 | }); |
| 332 | expect(session.deviceId).toBe("emulator-5554"); |
| 333 | expect(requests.filter((url) => url.endsWith("/boot"))).toHaveLength(1); |
| 334 | const state = yield* service.state; |
| 335 | expect(state.devices.map((device) => device.id)).toEqual(["emulator-5554"]); |
| 336 | expect(state.bootingDevices).toEqual([]); |
| 337 | }).pipe(Effect.scoped), |
| 338 | ); |
| 339 | |
| 340 | it.effect("installs agent support only after the separate agent permission", () => |
| 341 | Effect.gen(function* () { |
| 342 | const { service, agentStarts, agentStops, settings } = yield* fixture(); |
| 343 | yield* service.configure({ enabled: true }); |
| 344 | expect(agentStarts).toEqual([]); |
| 345 | expect(yield* service.agentReadinessIfSupported()).toBeNull(); |
| 346 | |
| 347 | yield* service.configure({ agentAccessEnabled: true }); |
| 348 | expect(agentStarts).toEqual(["start"]); |
| 349 | expect((yield* Ref.get(settings)).enableAgentDeviceAccess).toBe(true); |
| 350 | expect((yield* service.state).agentAccessEnabled).toBe(true); |
| 351 | |
| 352 | yield* service.configure({ agentAccessEnabled: false, onboardingCompleted: true }); |
| 353 | expect(agentStops).toEqual(["stop"]); |
| 354 | expect((yield* service.state).onboardingCompleted).toBe(true); |
| 355 | expect((yield* Ref.get(settings)).deviceOnboardingCompleted).toBe(true); |
| 356 | }).pipe(Effect.scoped), |
| 357 | ); |
| 358 | }); |
| 359 | |
| 360 | it.effect("publishes boot progress and does not restore sessions after support is disabled", () => |
| 361 | Effect.gen(function* () { |
| 362 | const started = yield* Deferred.make<void>(); |
| 363 | const finish = yield* Deferred.make<void>(); |
| 364 | const { service } = yield* fixture( |
| 365 | Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(finish))), |
| 366 | ); |
| 367 | yield* service.configure({ enabled: true }); |
| 368 | const opening = yield* service |
| 369 | .open({ threadId: ThreadId.make("thread-1"), deviceId: "Pixel_API_35", platform: "android" }) |
| 370 | .pipe(Effect.result, Effect.forkChild); |
| 371 | yield* Deferred.await(started); |
| 372 | expect((yield* service.state).bootingDevices?.map((device) => device.name)).toEqual([ |
| 373 | "Pixel_API_35", |
| 374 | ]); |
| 375 | yield* service.configure({ enabled: false }); |
| 376 | yield* Deferred.succeed(finish, undefined); |
| 377 | expect((yield* Fiber.join(opening))._tag).toBe("Failure"); |
| 378 | const state = yield* service.state; |
| 379 | expect(state.hostStatus).toBe("disabled"); |
| 380 | expect(state.devices).toEqual([]); |
| 381 | expect(state.sessions).toEqual([]); |
| 382 | expect(state.bootingDevices).toEqual([]); |
| 383 | }).pipe(Effect.scoped), |
| 384 | ); |
| 385 | |
| 386 | describe("device discovery after server restart", () => { |
| 387 | it.effect("captures an explicit device before any client lists devices", () => |
| 388 | Effect.gen(function* () { |
| 389 | const { service, settings, requests } = yield* fixture(); |
| 390 | yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); |
| 391 | expect((yield* service.state).devices).toEqual([]); |
| 392 | const capture = yield* service.screenshot({ deviceId: DeviceId.make("Pixel_API_35") }); |
| 393 | expect(capture.device.id).toBe("Pixel_API_35"); |
| 394 | expect(Array.from(capture.png)).toEqual([137, 80, 78, 71]); |
| 395 | expect(requests.some((url) => url.endsWith("/api/devices"))).toBe(true); |
| 396 | }).pipe(Effect.scoped), |
| 397 | ); |
| 398 | }); |
| 399 | |
| 400 | it.effect.each([ |
| 401 | { |
| 402 | diagnostic: "Insufficient disk space at /private/user/path", |
| 403 | reason: "disk_space", |
| 404 | message: "not enough free disk space", |
| 405 | }, |
| 406 | { |
| 407 | diagnostic: "Timed out spawning /private/user/command", |
| 408 | reason: "timeout", |
| 409 | message: "did not become ready in time", |
| 410 | }, |
| 411 | { |
| 412 | diagnostic: "Unexpected failure: secret-token", |
| 413 | reason: "launch_failed", |
| 414 | message: "could not start", |
| 415 | }, |
| 416 | ] as const)("normalizes boot failure: $reason", ({ diagnostic, message }) => |
| 417 | Effect.gen(function* () { |
| 418 | const { service } = yield* fixture(Effect.void, diagnostic); |
| 419 | yield* service.configure({ enabled: true }); |
| 420 | const error = yield* service |
| 421 | .open({ |
| 422 | threadId: ThreadId.make("boot-failure"), |
| 423 | deviceId: "Pixel_API_35", |
| 424 | platform: "android", |
| 425 | }) |
| 426 | .pipe(Effect.flip); |
| 427 | expect(error._tag).toBe("DeviceBootError"); |
| 428 | expect(error.message).toContain(message); |
| 429 | expect(error.message).not.toContain(diagnostic); |
| 430 | expect((yield* service.state).bootingDevices).toEqual([]); |
| 431 | }).pipe(Effect.scoped), |
| 432 | ); |
| 433 | |
| 434 | it.effect("keeps shutdown successful when subsequent discovery fails", () => |
| 435 | Effect.gen(function* () { |
| 436 | const { service } = yield* fixture(Effect.void, undefined, true); |
| 437 | yield* service.configure({ enabled: true }); |
| 438 | const threadId = ThreadId.make("shutdown-refresh"); |
| 439 | const session = yield* service.open({ |
| 440 | threadId, |
| 441 | deviceId: "Pixel_API_35", |
| 442 | platform: "android", |
| 443 | }); |
| 444 | yield* service.close({ threadId, deviceId: session.deviceId, shutdown: true }); |
| 445 | const state = yield* service.state; |
| 446 | expect(state.sessions).toEqual([]); |
| 447 | expect(state.devices.find((device) => device.id === session.deviceId)?.booted).toBe(false); |
| 448 | }).pipe(Effect.scoped), |
| 449 | ); |
| 450 | |
| 451 | it.effect.each(["shutdown", "close"] as const)( |
| 452 | "%s releases iOS capture so reopening uses a fresh session", |
| 453 | (operation) => |
| 454 | Effect.gen(function* () { |
| 455 | const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111"); |
| 456 | const threadId = ThreadId.make("capture-recovery"); |
| 457 | let booted = true; |
| 458 | let capture: number | null = null; |
| 459 | let generation = 0; |
| 460 | const ready: DeviceHost.DeviceHostReady = { |
| 461 | nodePath: process.execPath, |
| 462 | hub: { origin: "http://device.test" }, |
| 463 | helpers: { serveSimAxSettings: null, serveSimCli: null }, |
| 464 | run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), |
| 465 | }; |
| 466 | const host: DeviceHost.DeviceHost["Service"] = { |
| 467 | id: LOCAL_DEVICE_HOST_ID, |
| 468 | summary: Effect.succeed({ |
| 469 | id: LOCAL_DEVICE_HOST_ID, |
| 470 | kind: "local", |
| 471 | label: "Simulator host", |
| 472 | platforms: [{ platform: "ios", available: true }], |
| 473 | hubInstalled: true, |
| 474 | agentDeviceInstalled: false, |
| 475 | }), |
| 476 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 477 | ensureReady: () => Effect.succeed(ready), |
| 478 | ensureAgentReady: () => Effect.die("Agent access is not used in this test"), |
| 479 | current: Effect.succeed(ready), |
| 480 | stopAgent: Effect.void, |
| 481 | stop: Effect.void, |
| 482 | }; |
| 483 | const http = HttpClient.make((request) => |
| 484 | Effect.sync(() => { |
| 485 | const path = new URL(request.url).pathname; |
| 486 | if (path === "/api/devices") { |
| 487 | return HttpClientResponse.fromWeb( |
| 488 | request, |
| 489 | Response.json({ |
| 490 | emulators: [], |
| 491 | simulators: [ |
| 492 | { |
| 493 | id: deviceId, |
| 494 | name: "iPhone", |
| 495 | platform: "ios", |
| 496 | version: "26", |
| 497 | physical: false, |
| 498 | booted, |
| 499 | }, |
| 500 | ], |
| 501 | }), |
| 502 | ); |
| 503 | } |
| 504 | if (path === "/vendor/serve-sim/grid/api/start") capture ??= ++generation; |
| 505 | else if (path === "/vendor/serve-sim/grid/api/shutdown") { |
| 506 | if (request.body._tag !== "Uint8Array") throw new Error("Missing shutdown body"); |
| 507 | expect(decodeJson(new TextDecoder().decode(request.body.body))).toEqual({ |
| 508 | udid: deviceId, |
| 509 | }); |
| 510 | capture = null; |
| 511 | booted = false; |
| 512 | } else if (path === "/api/devices/shutdown") { |
| 513 | // This route powers off without releasing serve-sim's cached capture. |
| 514 | booted = false; |
| 515 | } else if (path === "/api/devices/boot") booted = true; |
| 516 | else throw new Error(`Unexpected hub path: ${path}`); |
| 517 | return HttpClientResponse.fromWeb(request, Response.json({ ok: true, id: deviceId })); |
| 518 | }), |
| 519 | ); |
| 520 | const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( |
| 521 | Effect.provide(NodeCrypto.layer), |
| 522 | Effect.provideService(HttpClient.HttpClient, http), |
| 523 | ); |
| 524 | const input = { threadId, deviceId, platform: "ios" as const }; |
| 525 | yield* service.open(input); |
| 526 | expect(capture).toBe(1); |
| 527 | if (operation === "shutdown") yield* service.shutdown(input); |
| 528 | else yield* service.close({ threadId, deviceId, shutdown: true }); |
| 529 | expect(capture).toBeNull(); |
| 530 | expect((yield* service.state).sessions).toEqual([]); |
| 531 | yield* service.open(input); |
| 532 | expect(capture).toBe(2); |
| 533 | expect((yield* service.state).sessions).toHaveLength(1); |
| 534 | }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), |
| 535 | ); |
| 536 | |
| 537 | it.effect.each([ |
| 538 | { hubReports: "off", outcome: "succeeds" }, |
| 539 | { hubReports: "booted", outcome: "fails" }, |
| 540 | { hubReports: "missing", outcome: "fails" }, |
| 541 | ] as const)( |
| 542 | "iOS shutdown $outcome when serve-sim rejects it and the hub reports the simulator $hubReports", |
| 543 | ({ hubReports, outcome }) => |
| 544 | Effect.gen(function* () { |
| 545 | const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222"); |
| 546 | const paths: string[] = []; |
| 547 | // The device list is stale until shutdown re-reads it from the hub. |
| 548 | let listed: "booted" | "off" | "missing" = "booted"; |
| 549 | const ready: DeviceHost.DeviceHostReady = { |
| 550 | nodePath: process.execPath, |
| 551 | hub: { origin: "http://device.test" }, |
| 552 | helpers: { serveSimAxSettings: null, serveSimCli: null }, |
| 553 | run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), |
| 554 | }; |
| 555 | const host: DeviceHost.DeviceHost["Service"] = { |
| 556 | id: LOCAL_DEVICE_HOST_ID, |
| 557 | summary: Effect.succeed({ |
| 558 | id: LOCAL_DEVICE_HOST_ID, |
| 559 | kind: "local", |
| 560 | label: "Simulator host", |
| 561 | platforms: [{ platform: "ios", available: true }], |
| 562 | hubInstalled: true, |
| 563 | agentDeviceInstalled: false, |
| 564 | }), |
| 565 | platformAvailability: (platform) => Effect.succeed({ platform, available: true }), |
| 566 | ensureReady: () => Effect.succeed(ready), |
| 567 | ensureAgentReady: () => Effect.die("Agent access is not used in this test"), |
| 568 | current: Effect.succeed(ready), |
| 569 | stopAgent: Effect.void, |
| 570 | stop: Effect.void, |
| 571 | }; |
| 572 | const http = HttpClient.make((request) => |
| 573 | Effect.sync(() => { |
| 574 | const path = new URL(request.url).pathname; |
| 575 | paths.push(path); |
| 576 | if (path === "/api/devices") { |
| 577 | return HttpClientResponse.fromWeb( |
| 578 | request, |
| 579 | Response.json({ |
| 580 | emulators: [], |
| 581 | simulators: |
| 582 | listed === "missing" |
| 583 | ? [] |
| 584 | : [ |
| 585 | { |
| 586 | id: deviceId, |
| 587 | name: "iPhone", |
| 588 | platform: "ios", |
| 589 | version: "26", |
| 590 | physical: false, |
| 591 | booted: listed === "booted", |
| 592 | }, |
| 593 | ], |
| 594 | // A partial listing still decodes; it must not read as "off". |
| 595 | errors: listed === "missing" ? [{ message: "simctl list failed" }] : [], |
| 596 | }), |
| 597 | ); |
| 598 | } |
| 599 | if (path === "/vendor/serve-sim/grid/api/shutdown") { |
| 600 | // serve-sim runs `simctl shutdown` bare and returns its failure as-is. |
| 601 | listed = hubReports; |
| 602 | return HttpClientResponse.fromWeb( |
| 603 | request, |
| 604 | Response.json( |
| 605 | { ok: false, error: "Unable to shutdown device in current state: Shutdown" }, |
| 606 | { status: 500 }, |
| 607 | ), |
| 608 | ); |
| 609 | } |
| 610 | throw new Error(`Unexpected hub path: ${path}`); |
| 611 | }), |
| 612 | ); |
| 613 | const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( |
| 614 | Effect.provide(NodeCrypto.layer), |
| 615 | Effect.provideService(HttpClient.HttpClient, http), |
| 616 | ); |
| 617 | yield* service.list; |
| 618 | const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" })); |
| 619 | expect(paths.filter((path) => path.endsWith("shutdown"))).toEqual([ |
| 620 | "/vendor/serve-sim/grid/api/shutdown", |
| 621 | ]); |
| 622 | if (outcome === "succeeds") { |
| 623 | expect(Exit.isSuccess(exit)).toBe(true); |
| 624 | expect( |
| 625 | (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, |
| 626 | ).toBe(false); |
| 627 | } else { |
| 628 | expect(Exit.isFailure(exit)).toBe(true); |
| 629 | expect( |
| 630 | (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, |
| 631 | ).toBe(true); |
| 632 | } |
| 633 | }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), |
| 634 | ); |
| 635 | |
| 636 | it.effect("retry keeps device and agent consent unchanged", () => |
| 637 | Effect.gen(function* () { |
| 638 | const { service, starts, agentStarts } = yield* fixture(); |
| 639 | yield* service.retryHost(LOCAL_DEVICE_HOST_ID); |
| 640 | expect(starts).toEqual([]); |
| 641 | expect(agentStarts).toEqual([]); |
| 642 | yield* service.configure({ enabled: true }); |
| 643 | yield* service.retryHost(LOCAL_DEVICE_HOST_ID); |
| 644 | expect(agentStarts).toEqual([]); |
| 645 | yield* service.configure({ agentAccessEnabled: true }); |
| 646 | const before = agentStarts.length; |
| 647 | yield* service.retryHost(LOCAL_DEVICE_HOST_ID); |
| 648 | expect(agentStarts.length).toBe(before + 1); |
| 649 | }).pipe(Effect.scoped), |
| 650 | ); |
| 651 | |
| 652 | it.effect("publishes update detail for the correct host", () => |
| 653 | Effect.gen(function* () { |
| 654 | const { service } = yield* fixture(); |
| 655 | const changes = yield* service.subscribe; |
| 656 | yield* service.configure({ enabled: true }); |
| 657 | const states = yield* PubSub.takeAll(changes); |
| 658 | expect( |
| 659 | states.some( |
| 660 | (state) => state.hostStatuses.local?.detail === "Updating device hub from 0.9.0 to 0.10.1…", |
| 661 | ), |
| 662 | ).toBe(true); |
| 663 | }).pipe(Effect.scoped), |
| 664 | ); |
| 665 | |
| 666 | it.effect("host retry exposes actionable failure without internal IDs or diagnostics", () => |
| 667 | Effect.gen(function* () { |
| 668 | const { service, settings } = yield* fixture( |
| 669 | Effect.void, |
| 670 | undefined, |
| 671 | false, |
| 672 | new DeviceHost.DeviceHostError({ |
| 673 | hostId: LOCAL_DEVICE_HOST_ID, |
| 674 | step: "probe", |
| 675 | cause: "private diagnostics", |
| 676 | }), |
| 677 | ); |
| 678 | yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); |
| 679 | const state = yield* service.retryHost(LOCAL_DEVICE_HOST_ID); |
| 680 | expect(state.supportsHostRetry).toBe(true); |
| 681 | expect(state.hostStatuses[LOCAL_DEVICE_HOST_ID]).toEqual({ |
| 682 | status: "failed", |
| 683 | detail: "Could not connect to this host over SSH.", |
| 684 | }); |
| 685 | }).pipe(Effect.scoped), |
| 686 | ); |
| 687 | |
| 688 | it.effect("version discovery does not grant consent or start device tools", () => |
| 689 | Effect.gen(function* () { |
| 690 | const { service, starts, agentStarts, requests } = yield* fixture(); |
| 691 | const state = yield* service.inspect; |
| 692 | expect(state.supportsToolInspection).toBe(true); |
| 693 | expect(state.hostStatus).toBe("disabled"); |
| 694 | expect(state.hosts).toHaveLength(1); |
| 695 | expect(starts).toEqual([]); |
| 696 | expect(agentStarts).toEqual([]); |
| 697 | expect(requests).toEqual([]); |
| 698 | }).pipe(Effect.scoped), |
| 699 | ); |
| 700 | |
| 701 | it.effect("failed read-only discovery preserves lifecycle status and installed inventory", () => |
| 702 | Effect.gen(function* () { |
| 703 | const { service, starts } = yield* fixture(Effect.void, undefined, false, undefined, true); |
| 704 | const state = yield* service.inspect; |
| 705 | expect(state.supportsToolInspection).toBe(true); |
| 706 | expect(state.hostStatus).toBe("disabled"); |
| 707 | expect(state.hosts[0]?.hubInstalled).toBe(true); |
| 708 | expect(state.hosts[0]?.toolInspectionError).toContain("Reconnect the host"); |
| 709 | expect(starts).toEqual([]); |
| 710 | }).pipe(Effect.scoped), |
| 711 | ); |
| 712 | |
| 713 | it.effect( |
| 714 | "manual updates install only the selected tool without enabling access or starting helpers", |
| 715 | () => |
| 716 | Effect.gen(function* () { |
| 717 | const installed: string[] = []; |
| 718 | const { service, starts, agentStarts, requests } = yield* fixture( |
| 719 | Effect.void, |
| 720 | undefined, |
| 721 | false, |
| 722 | undefined, |
| 723 | false, |
| 724 | (tool) => |
| 725 | Effect.sync(() => { |
| 726 | installed.push(tool); |
| 727 | }), |
| 728 | ); |
| 729 | const before = yield* service.state; |
| 730 | const state = yield* service.updateTool("agent"); |
| 731 | expect(installed).toEqual(["agent"]); |
| 732 | expect(state.supportsToolUpdate).toBe(true); |
| 733 | expect(state.hostStatus).toBe(before.hostStatus); |
| 734 | expect(state.agentAccessEnabled).toBe(before.agentAccessEnabled); |
| 735 | expect(state.revision).toBeGreaterThan(before.revision); |
| 736 | expect(starts).toEqual([]); |
| 737 | expect(agentStarts).toEqual([]); |
| 738 | expect(requests).toEqual([]); |
| 739 | yield* service.updateTool("hub"); |
| 740 | expect(installed).toEqual(["agent", "hub"]); |
| 741 | }).pipe(Effect.scoped), |
| 742 | ); |
| 743 | |
| 744 | it.effect("failed manual installation leaves lifecycle state unchanged and can be retried", () => |
| 745 | Effect.gen(function* () { |
| 746 | let attempts = 0; |
| 747 | const { service, starts, agentStarts } = yield* fixture( |
| 748 | Effect.void, |
| 749 | undefined, |
| 750 | false, |
| 751 | undefined, |
| 752 | false, |
| 753 | () => |
| 754 | Effect.suspend(() => |
| 755 | ++attempts === 1 |
| 756 | ? Effect.fail( |
| 757 | new DeviceOperationError({ |
| 758 | operation: "update device tool", |
| 759 | reason: "command_failed", |
| 760 | cause: new Error("offline"), |
| 761 | }), |
| 762 | ) |
| 763 | : Effect.void, |
| 764 | ), |
| 765 | ); |
| 766 | const before = yield* service.state; |
| 767 | const result = yield* service.updateTool("agent").pipe(Effect.result); |
| 768 | expect(result._tag).toBe("Failure"); |
| 769 | expect(yield* service.state).toEqual(before); |
| 770 | yield* service.updateTool("agent"); |
| 771 | expect(attempts).toBe(2); |
| 772 | expect(starts).toEqual([]); |
| 773 | expect(agentStarts).toEqual([]); |
| 774 | }).pipe(Effect.scoped), |
| 775 | ); |