diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index c70f30b1fe87..183f24d95c5b 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -7,6 +7,7 @@ import { type DeviceServiceState, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as PubSub from "effect/PubSub"; @@ -469,3 +470,104 @@ it.effect.each(["shutdown", "close"] as const)( Effect.scoped, ), ); + +it.effect.each([ + { hubReports: "off", outcome: "succeeds" }, + { hubReports: "booted", outcome: "fails" }, + { hubReports: "missing", outcome: "fails" }, +] as const)( + "iOS shutdown $outcome when serve-sim rejects it and the hub reports the simulator $hubReports", + ({ hubReports, outcome }) => + Effect.gen(function* () { + const deviceId = DeviceId.make("22222222-2222-2222-2222-222222222222"); + const paths: string[] = []; + // The device list is stale until shutdown re-reads it from the hub. + let listed: "booted" | "off" | "missing" = "booted"; + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.succeed({ code: 0, stdout: "", stderr: "" }), + }; + const host: DeviceHost.DeviceHost["Service"] = { + id: LOCAL_DEVICE_HOST_ID, + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Simulator host", + platforms: [{ platform: "ios", available: true }], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + paths.push(path); + if (path === "/api/devices") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + emulators: [], + simulators: + listed === "missing" + ? [] + : [ + { + id: deviceId, + name: "iPhone", + platform: "ios", + version: "26", + physical: false, + booted: listed === "booted", + }, + ], + // A partial listing still decodes; it must not read as "off". + errors: listed === "missing" ? [{ message: "simctl list failed" }] : [], + }), + ); + } + if (path === "/vendor/serve-sim/grid/api/shutdown") { + // serve-sim runs `simctl shutdown` bare and returns its failure as-is. + listed = hubReports; + return HttpClientResponse.fromWeb( + request, + Response.json( + { ok: false, error: "Unable to shutdown device in current state: Shutdown" }, + { status: 500 }, + ), + ); + } + throw new Error(`Unexpected hub path: ${path}`); + }), + ); + const service = yield* makeWithHosts(new Map([[host.id, host]])).pipe( + Effect.provideService(HttpClient.HttpClient, http), + ); + yield* service.list; + const exit = yield* Effect.exit(service.shutdown({ deviceId, platform: "ios" })); + expect(paths.filter((path) => path.endsWith("shutdown"))).toEqual([ + "/vendor/serve-sim/grid/api/shutdown", + ]); + if (outcome === "succeeds") { + expect(Exit.isSuccess(exit)).toBe(true); + expect( + (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, + ).toBe(false); + } else { + expect(Exit.isFailure(exit)).toBe(true); + expect( + (yield* service.state).devices.find((device) => device.id === deviceId)?.booted, + ).toBe(true); + } + }).pipe( + Effect.provide(ServerSettingsService.layerTest({ enableDeviceSupport: true })), + Effect.scoped, + ), +); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 8b7db9b5e590..45a2f928ecaa 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -670,31 +670,44 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* platform: DevicePlatform, ) { const ready = yield* readiness(hostId); - // serve-sim's shutdown closes its in-process capture session before powering off. - // The hub's generic shutdown can leave that session cached across a reboot. - const path = - platform === "ios" ? `${vendorPrefix("ios")}/grid/api/shutdown` : "/api/devices/shutdown"; - yield* HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe( - HttpClientRequest.bodyJson( - platform === "ios" ? { udid: deviceId } : { platform, id: deviceId }, - ), - Effect.mapError( - (cause) => - new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), - ), - Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), - Effect.flatMap((result) => - result.ok - ? Effect.void - : Effect.fail( - new DeviceOperationError({ - operation: "shutdown", - reason: "hub_rejected", - cause: result, - }), + const postShutdown = (path: string, body: Record) => + HttpClientRequest.post(`${ready.hub.origin}${path}`).pipe( + HttpClientRequest.bodyJson(body), + Effect.mapError( + (cause) => + new DeviceOperationError({ operation: "shutdown", reason: "invalid_payload", cause }), + ), + Effect.flatMap((request) => hubJson(request, HubActionResult, "shutdown")), + Effect.flatMap((result) => + result.ok + ? Effect.void + : Effect.fail( + new DeviceOperationError({ + operation: "shutdown", + reason: "hub_rejected", + cause: result, + }), + ), + ), + ); + // serve-sim's shutdown closes its in-process capture session before it runs + // `simctl shutdown`; the hub's generic shutdown can leave that session cached + // across a reboot. serve-sim runs simctl bare, though, so a simulator that is + // already off fails there. Accept that failure only when the hub confirms + // the simulator is off; a failure on a running one still surfaces. + yield* platform === "ios" + ? postShutdown(`${vendorPrefix("ios")}/grid/api/shutdown`, { udid: deviceId }).pipe( + Effect.catch((cause) => + fetchDevices(ready).pipe( + Effect.flatMap(({ devices }) => + devices.find((device) => device.id === deviceId)?.booted === false + ? Effect.logInfo("iOS simulator was already shut down", { deviceId }) + : Effect.fail(cause), + ), ), - ), - ); + ), + ) + : postShutdown("/api/devices/shutdown", { platform, id: deviceId }); yield* publish((state) => ({ ...state, devices: state.devices.map((device) =>