From 6b464d80dd8def5db795b5530dfd0cb01f9ad452 Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:05:39 -0500 Subject: [PATCH 1/5] fix(server): discover never-used iOS simulators --- .../src/device/DeviceHubOperations.test.ts | 128 ++++++++++ apps/server/src/device/DeviceHubOperations.ts | 45 ++++ .../server/src/device/DeviceMultiHost.test.ts | 1 + apps/server/src/device/DeviceService.test.ts | 6 + apps/server/src/device/LocalDeviceHost.ts | 3 + apps/server/src/device/SshDeviceHost.test.ts | 230 ++++++++++-------- apps/server/src/device/SshDeviceHost.ts | 7 + 7 files changed, 317 insertions(+), 103 deletions(-) create mode 100644 apps/server/src/device/DeviceHubOperations.test.ts diff --git a/apps/server/src/device/DeviceHubOperations.test.ts b/apps/server/src/device/DeviceHubOperations.test.ts new file mode 100644 index 0000000000000..6250704bdab5f --- /dev/null +++ b/apps/server/src/device/DeviceHubOperations.test.ts @@ -0,0 +1,128 @@ +import { expect, it } from "@effect/vitest"; +import { LOCAL_DEVICE_HOST_ID } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientResponse } from "effect/http"; + +import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; + +it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( + "discovers and boots never-used iOS simulators on %s through the hub", + (hostId) => + Effect.gen(function* () { + const used = { + id: "used", + name: "Used iPhone", + version: "iOS 27.0", + platform: "ios", + booted: true, + physical: false, + }; + const bootBodies: unknown[] = []; + const attached: unknown[] = []; + const http = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname; + let body: unknown; + if (path === "/api/devices") { + // The hub's recent-device inventory omits simulators without usage timestamps. + body = { simulators: [used], emulators: [] }; + } else if (path === "/vendor/serve-sim/grid/api") { + body = { + devices: [ + { device: "used", name: "Grid name", runtime: "iOS-27-0", state: "Booted" }, + { device: "fresh", name: "Fresh iPhone", runtime: "iOS-27-0", state: "Shutdown" }, + { device: "watch", name: "Watch", runtime: "watchOS-27-0", state: "Shutdown" }, + ], + }; + } else { + if (request.body._tag !== "Uint8Array") throw new Error("Missing action body"); + const payload: unknown = JSON.parse(new TextDecoder().decode(request.body.body)); + if (path === "/api/devices/boot") bootBodies.push(payload); + else if (path === "/vendor/serve-sim/grid/api/start") attached.push(payload); + else throw new Error(`Unexpected hub path: ${path}`); + body = { ok: true }; + } + return HttpClientResponse.fromWeb(request, Response.json(body)); + }), + ); + const operations = makeDeviceHubOperations({ + hostId, + origin: "http://device.test", + http, + run: () => Effect.die("iOS discovery uses the hub"), + iosAvailable: Effect.succeed(true), + androidAvailable: Effect.succeed(false), + }); + const listed = yield* operations.list; + expect(listed.devices).toEqual([ + used, + { + id: "fresh", + name: "Fresh iPhone", + version: "iOS 27.0", + platform: "ios", + booted: false, + physical: false, + }, + ]); + const fresh = listed.devices.find((device) => device.id === "fresh")!; + expect(yield* operations.boot(fresh)).toBe("fresh"); + expect(bootBodies).toEqual([{ platform: "ios", id: "fresh", name: "Fresh iPhone" }]); + expect(attached).toEqual([{ udid: "fresh" }]); + }), +); + +it.effect.each(["unavailable", "http-error", "invalid-json"])( + "keeps hub inventory and diagnostics when iOS grid discovery is %s", + (scenario) => + Effect.gen(function* () { + const used = { + id: "used", + name: "Used iPhone", + version: "iOS 27.0", + platform: "ios", + booted: true, + physical: false, + }; + let recovered = false; + const operations = makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http: HttpClient.make((request) => + Effect.sync(() => { + if (request.url.endsWith("/api/devices")) { + return HttpClientResponse.fromWeb( + request, + Response.json({ + simulators: [used], + emulators: [], + errors: [{ message: "Existing discovery warning" }], + }), + ); + } + if (scenario === "unavailable") throw new Error("iOS must not be probed here"); + return HttpClientResponse.fromWeb( + request, + recovered + ? Response.json({ devices: [] }) + : scenario === "http-error" + ? new Response("private diagnostics", { status: 500 }) + : Response.json({ unexpected: "private diagnostics" }), + ); + }), + ), + run: () => Effect.die("Discovery must not run a command"), + iosAvailable: Effect.succeed(scenario !== "unavailable"), + androidAvailable: Effect.succeed(false), + }); + const listed = yield* operations.list; + expect(listed.devices).toEqual([used]); + expect(listed.detail).toBe( + scenario === "unavailable" + ? "Existing discovery warning" + : "Existing discovery warning\nCould not list all iOS simulators. Recently used simulators remain available.", + ); + recovered = true; + expect((yield* operations.list).detail).toBe("Existing discovery warning"); + }), +); diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 772b626619487..16f37872e81bf 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -29,6 +29,16 @@ const HubDeviceList = Schema.Struct({ Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })), ), }); +const IosGridDeviceList = Schema.Struct({ + devices: Schema.Array( + Schema.Struct({ + device: Schema.String, + name: Schema.String, + runtime: Schema.String, + state: Schema.String, + }), + ), +}); const HubActionResult = Schema.Struct({ ok: Schema.Boolean, id: Schema.optional(Schema.String), @@ -63,6 +73,7 @@ export const makeDeviceHubOperations = (options: { readonly origin: string; readonly http: HttpClient.HttpClient; readonly run: DeviceHostReady["run"]; + readonly iosAvailable: Effect.Effect; /** Whether to list AVDs, which the hub does not report until they boot. */ readonly androidAvailable: Effect.Effect; }): DeviceOperations => { @@ -129,6 +140,40 @@ export const makeDeviceHubOperations = (options: { const head = errorHead(error.error ?? ""); return boundDiagnostics(head ? `${error.message} ${head}` : error.message); }) ?? []; + if (yield* options.iosAvailable) { + // The hub's recent-device list excludes never-used simulators; its grid includes them + // and retains the hub's availability and single-device session filters. + const grid = yield* hubJson( + HttpClientRequest.get(`${origin}${vendorPrefix("ios")}/grid/api`), + IosGridDeviceList, + "list", + ).pipe(Effect.result); + if (grid._tag === "Failure") { + details.push( + "Could not list all iOS simulators. Recently used simulators remain available.", + ); + } else { + for (const device of grid.success.devices) { + if ( + !/^iOS-\d+(?:-\d+)*$/.test(device.runtime) || + devices.some( + (listed) => + listed.platform === "ios" && + listed.id.toUpperCase() === device.device.toUpperCase(), + ) + ) + continue; + devices.push({ + id: device.device, + name: device.name, + version: device.runtime.replace("iOS-", "iOS ").replaceAll("-", "."), + platform: "ios", + booted: device.state === "Booted", + physical: false, + }); + } + } + } let androidDiscoveryIncomplete = false; if (androidAvailable) { const avds = yield* run("emulator", ["-list-avds"]); diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index 44115303e7b8a..ab7d7a7d77409 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -52,6 +52,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", origin: `http://${id}`, http, run, + iosAvailable: Effect.succeed(false), androidAvailable: Effect.succeed(true), }), agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index cdcb100918beb..2841a786fc3d5 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -55,6 +55,7 @@ it.effect.each([400, 500])( ), ), run: () => Effect.die("Screenshot does not run a command"), + iosAvailable: Effect.succeed(false), androidAvailable: Effect.succeed(false), }); const result = yield* operations @@ -201,6 +202,7 @@ const fixture = Effect.fn("fixture")(function* ( origin: "http://device.test", http, run, + iosAvailable: Effect.succeed(false), androidAvailable: Ref.get(androidAvailable), }), ...(options.endsDevices ? { endsDevices: true } : {}), @@ -824,6 +826,7 @@ it.effect.each(["shutdown", "close"] as const)( origin: "http://device.test", http, run, + iosAvailable: Effect.succeed(false), androidAvailable: Effect.succeed(true), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, @@ -986,6 +989,7 @@ it.effect("leaves a session its host leases to every thread open when one thread origin: "http://device.test", http, run, + iosAvailable: Effect.succeed(false), androidAvailable: Effect.succeed(true), }), agentEndpoint: () => @@ -1108,6 +1112,7 @@ it.effect.each([ origin: "http://device.test", http, run, + iosAvailable: Effect.succeed(false), androidAvailable: Effect.succeed(true), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, @@ -1180,6 +1185,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( origin: "http://device.test", http, run, + iosAvailable: Effect.succeed(false), androidAvailable: Effect.succeed(true), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 13ad97fcec660..2a3b9d1f5f188 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -823,6 +823,9 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { origin: running.hub.origin, http: httpClient, run, + iosAvailable: platformAvailability("ios").pipe( + Effect.map((availability) => availability.available), + ), androidAvailable: platformAvailability("android").pipe( Effect.map((availability) => availability.available), ), diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index f074c19d8992e..4dbb50a4c1a66 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -194,117 +194,141 @@ it.effect("preserves installed status after probes and cleans failed agent activ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); -it.effect("lists Android devices without probing the host again once it reports Android", () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const home = yield* fs.makeTempDirectoryScoped(); - const modes: string[] = []; - let androidInstalled = false; - const spawner = ChildProcessSpawner.make((command) => - Effect.gen(function* () { - if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); - const forwarding = command.args.includes("-L"); - let output = ""; - if (!forwarding) { - const stdin = command.options.stdin; - const script = - stdin && typeof stdin === "object" && "stream" in stdin && Stream.isStream(stdin.stream) - ? yield* stdin.stream.pipe( - Stream.decodeText(), - Stream.runFold( - () => "", - (a, b) => a + b, - ), - ) - : ""; - const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; - modes.push(mode); - output = - mode === "" - ? "Pixel_8\n" - : JSON.stringify({ - nodePath: "/node", - platforms: [ - { platform: "ios", available: true }, - { platform: "android", available: androidInstalled }, - ], - hubPort: 1234, - helpers: { serveSimAxSettings: null, serveSimCli: null }, - }); - } - return ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.make(new TextEncoder().encode(output)), - stderr: Stream.empty, - all: Stream.empty, - exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), - isRunning: Effect.succeed(forwarding), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }); - }), - ); - const host = yield* SshDeviceHost.make({ - kind: "ssh", - id: "test", - label: "Test", - target: "test.example", - }).pipe( - Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response( - request.url.endsWith("/api/devices") - ? JSON.stringify({ simulators: [], emulators: [] }) - : "ok", +it.effect.each(["ios", "android"] as const)( + "discovers %s tools installed after connecting without repeating positive probes", + (platform) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const home = yield* fs.makeTempDirectoryScoped(); + const modes: string[] = []; + let platformInstalled = false; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); + const forwarding = command.args.includes("-L"); + let output = ""; + if (!forwarding) { + const stdin = command.options.stdin; + const script = + stdin && + typeof stdin === "object" && + "stream" in stdin && + Stream.isStream(stdin.stream) + ? yield* stdin.stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (a, b) => a + b, + ), + ) + : ""; + const mode = /const mode = "([^"]+)"/.exec(script)?.[1] ?? ""; + modes.push(mode); + output = + mode === "" + ? "Pixel_8\n" + : JSON.stringify({ + nodePath: "/node", + platforms: [ + { platform: "ios", available: platform === "ios" ? platformInstalled : true }, + { + platform: "android", + available: platform === "android" ? platformInstalled : true, + }, + ], + hubPort: 1234, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }); + } + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.make(new TextEncoder().encode(output)), + stderr: Stream.empty, + all: Stream.empty, + exitCode: forwarding ? Effect.never : Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(forwarding), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }); + }), + ); + const host = yield* SshDeviceHost.make({ + kind: "ssh", + id: "test", + label: "Test", + target: "test.example", + }).pipe( + Effect.provide(Layer.mergeAll(ServerConfig.layerTest(home, home), Net.layer)), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response( + request.url.endsWith("/api/devices") + ? JSON.stringify({ simulators: [], emulators: [] }) + : request.url.endsWith("/grid/api") + ? JSON.stringify({ + devices: [ + { + device: "fresh", + name: "Fresh iPhone", + runtime: "iOS-27-0", + state: "Shutdown", + }, + ], + }) + : "ok", + ), ), ), ), ), - ), - ); - const ready = yield* host.ensureReady(() => Effect.void); - const androidNames = ready.devices.list.pipe( - Effect.map((list) => - list.devices.filter((device) => device.platform === "android").map((device) => device.name), - ), - ); + ); + const ready = yield* host.ensureReady(() => Effect.void); + const platformNames = ready.devices.list.pipe( + Effect.map((list) => + list.devices + .filter((device) => device.platform === platform) + .map((device) => device.name), + ), + ); - modes.splice(0); + modes.splice(0); + const observedModes = () => + modes.splice(0).filter((mode) => platform === "android" || mode !== ""); + const enumerationModes = platform === "android" ? [""] : []; + const names = platform === "android" ? ["Pixel_8"] : ["Fresh iPhone"]; - expect(yield* androidNames).toEqual([]); - expect(modes.splice(0)).toEqual(["probe"]); - expect(yield* androidNames).toEqual([]); - expect(modes.splice(0)).toEqual(["probe"]); + expect(yield* platformNames).toEqual([]); + expect(yield* platformNames).toEqual([]); + modes.splice(0); - // The SDK appears after connecting, so the next listing still probes the host. - androidInstalled = true; - expect(yield* androidNames).toEqual(["Pixel_8"]); - expect(modes.splice(0)).toEqual(["probe", ""]); + // Tools can become available after connecting, so negative reports still need probing. + platformInstalled = true; + expect(yield* platformNames).toEqual(names); + expect(observedModes()).toEqual(["probe", ...enumerationModes]); - // Android is now known to be available, so listing only runs `emulator -list-avds`. - expect(yield* androidNames).toEqual(["Pixel_8"]); - expect(modes.splice(0)).toEqual([""]); + // Once available, discovery no longer needs a platform probe. + expect(yield* platformNames).toEqual(names); + expect(observedModes()).toEqual(enumerationModes); - androidInstalled = false; - expect((yield* host.platformAvailability("android")).available).toBe(false); - expect(modes.splice(0)).toEqual(["probe"]); - expect(yield* androidNames).toEqual([]); - expect(modes.splice(0)).toEqual(["probe"]); + platformInstalled = false; + expect((yield* host.platformAvailability(platform)).available).toBe(false); + expect(observedModes()).toEqual(["probe"]); + expect(yield* platformNames).toEqual([]); + expect(observedModes()).toEqual(["probe"]); - androidInstalled = true; - yield* host.inspect; - expect(modes.splice(0)).toEqual(["probe"]); - expect(yield* androidNames).toEqual(["Pixel_8"]); - expect(modes.splice(0)).toEqual([""]); - yield* host.stop; - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + platformInstalled = true; + yield* host.inspect; + expect(observedModes()).toEqual(["probe"]); + expect(yield* platformNames).toEqual(names); + expect(observedModes()).toEqual(enumerationModes); + yield* host.stop; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index c96011260f268..d4497ee727add 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -318,6 +318,13 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( http, run, // Keep probing negative reports so an SDK installed after connecting is still found. + iosAvailable: Effect.suspend(() => + summary.platforms.some((p) => p.platform === "ios" && p.available) + ? Effect.succeed(true) + : platformAvailability("ios").pipe( + Effect.map((availability) => availability.available), + ), + ), androidAvailable: Effect.suspend(() => summary.platforms.some((p) => p.platform === "android" && p.available) ? Effect.succeed(true) From ae18f147f3fe447322f204854de1dc8f9f825aaa Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:23:22 -0500 Subject: [PATCH 2/5] fix(server): share SSH platform probes during device discovery --- .../src/device/DeviceHubOperations.test.ts | 6 ++-- apps/server/src/device/DeviceHubOperations.ts | 12 ++++---- .../server/src/device/DeviceMultiHost.test.ts | 3 +- apps/server/src/device/DeviceService.test.ts | 20 ++++++------- apps/server/src/device/LocalDeviceHost.ts | 12 ++++---- apps/server/src/device/SshDeviceHost.test.ts | 22 +++++++++++++-- apps/server/src/device/SshDeviceHost.ts | 28 +++++++++---------- 7 files changed, 56 insertions(+), 47 deletions(-) diff --git a/apps/server/src/device/DeviceHubOperations.test.ts b/apps/server/src/device/DeviceHubOperations.test.ts index 6250704bdab5f..b0450fd77c024 100644 --- a/apps/server/src/device/DeviceHubOperations.test.ts +++ b/apps/server/src/device/DeviceHubOperations.test.ts @@ -50,8 +50,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( origin: "http://device.test", http, run: () => Effect.die("iOS discovery uses the hub"), - iosAvailable: Effect.succeed(true), - androidAvailable: Effect.succeed(false), + availablePlatforms: Effect.succeed({ ios: true, android: false }), }); const listed = yield* operations.list; expect(listed.devices).toEqual([ @@ -112,8 +111,7 @@ it.effect.each(["unavailable", "http-error", "invalid-json"])( }), ), run: () => Effect.die("Discovery must not run a command"), - iosAvailable: Effect.succeed(scenario !== "unavailable"), - androidAvailable: Effect.succeed(false), + availablePlatforms: Effect.succeed({ ios: scenario !== "unavailable", android: false }), }); const listed = yield* operations.list; expect(listed.devices).toEqual([used]); diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 16f37872e81bf..02c8b0951a04a 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -73,9 +73,7 @@ export const makeDeviceHubOperations = (options: { readonly origin: string; readonly http: HttpClient.HttpClient; readonly run: DeviceHostReady["run"]; - readonly iosAvailable: Effect.Effect; - /** Whether to list AVDs, which the hub does not report until they boot. */ - readonly androidAvailable: Effect.Effect; + readonly availablePlatforms: Effect.Effect<{ readonly ios: boolean; readonly android: boolean }>; }): DeviceOperations => { const { hostId, origin, run } = options; const httpClient = options.http.pipe(HttpClient.withScope); @@ -123,11 +121,11 @@ export const makeDeviceHubOperations = (options: { "list", ); const devices: Array = [...hubList.simulators, ...hubList.emulators]; - const androidAvailable = yield* options.androidAvailable; + const available = yield* options.availablePlatforms; const warnings = hubList.errors?.filter( (error) => // The host summary already explains a missing Android SDK; the hub's failed probes are noise. - !(!androidAvailable && error.message.startsWith("[android-utils] ")) && + !(!available.android && error.message.startsWith("[android-utils] ")) && // CoreSimulator can retain deleted devices; their optional usage timestamps are absent. !( error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") && @@ -140,7 +138,7 @@ export const makeDeviceHubOperations = (options: { const head = errorHead(error.error ?? ""); return boundDiagnostics(head ? `${error.message} ${head}` : error.message); }) ?? []; - if (yield* options.iosAvailable) { + if (available.ios) { // The hub's recent-device list excludes never-used simulators; its grid includes them // and retains the hub's availability and single-device session filters. const grid = yield* hubJson( @@ -175,7 +173,7 @@ export const makeDeviceHubOperations = (options: { } } let androidDiscoveryIncomplete = false; - if (androidAvailable) { + if (available.android) { const avds = yield* run("emulator", ["-list-avds"]); // Discovered devices stay listed; the AVDs that could not be listed are a warning. if (avds.code !== 0) { diff --git a/apps/server/src/device/DeviceMultiHost.test.ts b/apps/server/src/device/DeviceMultiHost.test.ts index ab7d7a7d77409..16f706b59a10f 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -52,8 +52,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", origin: `http://${id}`, http, run, - iosAvailable: Effect.succeed(false), - androidAvailable: Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), agentDevice: { baseUrl: `http://${id}`, token: "test", entryPath: "/agent-device" }, releaseAgentSession: releaseAgentDeviceSession(http, { diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 33a6e2da56f37..7fd37f35f6f72 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -55,8 +55,7 @@ it.effect.each([400, 500])( ), ), run: () => Effect.die("Screenshot does not run a command"), - iosAvailable: Effect.succeed(false), - androidAvailable: Effect.succeed(false), + availablePlatforms: Effect.succeed({ ios: false, android: false }), }); const result = yield* operations .screenshot({ @@ -202,8 +201,9 @@ const fixture = Effect.fn("fixture")(function* ( origin: "http://device.test", http, run, - iosAvailable: Effect.succeed(false), - androidAvailable: Ref.get(androidAvailable), + availablePlatforms: Ref.get(androidAvailable).pipe( + Effect.map((android) => ({ ios: false, android })), + ), }), ...(options.endsDevices ? { endsDevices: true } : {}), }, @@ -826,8 +826,7 @@ it.effect.each(["shutdown", "close"] as const)( origin: "http://device.test", http, run, - iosAvailable: Effect.succeed(false), - androidAvailable: Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, run, @@ -989,8 +988,7 @@ it.effect("leaves a session its host leases to every thread open when one thread origin: "http://device.test", http, run, - iosAvailable: Effect.succeed(false), - androidAvailable: Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), agentEndpoint: () => Effect.succeed({ @@ -1112,8 +1110,7 @@ it.effect.each([ origin: "http://device.test", http, run, - iosAvailable: Effect.succeed(false), - androidAvailable: Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, run, @@ -1185,8 +1182,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( origin: "http://device.test", http, run, - iosAvailable: Effect.succeed(false), - androidAvailable: Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, run, diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 2a3b9d1f5f188..8172a4ce3ba6f 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -823,12 +823,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { origin: running.hub.origin, http: httpClient, run, - iosAvailable: platformAvailability("ios").pipe( - Effect.map((availability) => availability.available), - ), - androidAvailable: platformAvailability("android").pipe( - Effect.map((availability) => availability.available), - ), + availablePlatforms: Effect.all({ + ios: platformAvailability("ios").pipe(Effect.map((availability) => availability.available)), + android: platformAvailability("android").pipe( + Effect.map((availability) => availability.available), + ), + }), }), nodePath: running.hub.nodePath, run, diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index e45724a12c43a..21e087ad153b0 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -203,6 +203,7 @@ it.effect.each(["ios", "android"] as const)( const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; let platformInstalled = false; + let otherPlatformInstalled = true; const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { if (command._tag !== "StandardCommand") return yield* Effect.die("Unexpected command"); @@ -231,10 +232,14 @@ it.effect.each(["ios", "android"] as const)( : JSON.stringify({ nodePath: "/node", platforms: [ - { platform: "ios", available: platform === "ios" ? platformInstalled : true }, + { + platform: "ios", + available: platform === "ios" ? platformInstalled : otherPlatformInstalled, + }, { platform: "android", - available: platform === "android" ? platformInstalled : true, + available: + platform === "android" ? platformInstalled : otherPlatformInstalled, }, ], hubPort: 1234, @@ -330,6 +335,19 @@ it.effect.each(["ios", "android"] as const)( expect(observedModes()).toEqual(["probe"]); expect(yield* platformNames).toEqual(names); expect(observedModes()).toEqual(enumerationModes); + + platformInstalled = false; + otherPlatformInstalled = false; + yield* host.inspect; + modes.splice(0); + expect(yield* platformNames).toEqual([]); + expect(observedModes()).toEqual(["probe"]); + expect(yield* platformNames).toEqual([]); + expect(observedModes()).toEqual(["probe"]); + + platformInstalled = true; + expect(yield* platformNames).toEqual(names); + expect(observedModes()).toEqual(["probe", ...enumerationModes]); yield* host.stop; }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 6d54be58ab09b..6ea1bb34c19b2 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -1,5 +1,6 @@ import { type DeviceHostSummary, + type DevicePlatform, DevicePlatformAvailability, DeviceToolVersions, deviceToolInstallMessage, @@ -321,20 +322,19 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( http, run, // Keep probing negative reports so an SDK installed after connecting is still found. - iosAvailable: Effect.suspend(() => - summary.platforms.some((p) => p.platform === "ios" && p.available) - ? Effect.succeed(true) - : platformAvailability("ios").pipe( - Effect.map((availability) => availability.available), - ), - ), - androidAvailable: Effect.suspend(() => - summary.platforms.some((p) => p.platform === "android" && p.available) - ? Effect.succeed(true) - : platformAvailability("android").pipe( - Effect.map((availability) => availability.available), - ), - ), + availablePlatforms: Effect.gen(function* () { + const isAvailable = (platform: DevicePlatform) => + summary.platforms.some( + (candidate) => candidate.platform === platform && candidate.available, + ); + if (!isAvailable("ios") || !isAvailable("android")) { + const refreshed = yield* provide(probe(config, owner)).pipe(Effect.result); + if (refreshed._tag === "Success") { + summary = { ...summary, platforms: refreshed.success.platforms }; + } + } + return { ios: isAvailable("ios"), android: isAvailable("android") }; + }), }), ...(remote.daemonPort !== undefined && remote.token !== undefined && From 213741aa67208f57bd6a5b024f9ce154cb1f55e4 Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:39:27 -0500 Subject: [PATCH 3/5] fix(server): skip unavailable iOS probes on non-Mac SSH hosts --- apps/server/src/device/SshDeviceHost.test.ts | 22 +++++++++++++++----- apps/server/src/device/SshDeviceHost.ts | 6 +++++- apps/server/src/device/sshDeviceScript.ts | 2 +- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index 21e087ad153b0..e6ba5be8c4a7b 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -73,6 +73,7 @@ it.effect("preserves installed status after probes and cleans failed agent activ owners.push(/const owner = "([^"]+)"/.exec(script)?.[1] ?? ""); output = JSON.stringify({ nodePath: "/node", + hostPlatform: "darwin", platforms: [{ platform: "ios", available: true }], hubPort: 1234, helpers: { serveSimAxSettings: null, serveSimCli: null }, @@ -195,14 +196,18 @@ it.effect("preserves installed status after probes and cleans failed agent activ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); -it.effect.each(["ios", "android"] as const)( - "discovers %s tools installed after connecting without repeating positive probes", - (platform) => +it.effect.each([ + { platform: "ios", hostPlatform: "darwin" }, + { platform: "android", hostPlatform: "darwin" }, + { platform: "android", hostPlatform: "linux" }, +] as const)( + "discovers $platform tools on $hostPlatform without repeating positive probes", + ({ platform, hostPlatform }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const home = yield* fs.makeTempDirectoryScoped(); const modes: string[] = []; - let platformInstalled = false; + let platformInstalled = hostPlatform !== "darwin"; let otherPlatformInstalled = true; const spawner = ChildProcessSpawner.make((command) => Effect.gen(function* () { @@ -231,10 +236,13 @@ it.effect.each(["ios", "android"] as const)( ? "Pixel_8\n" : JSON.stringify({ nodePath: "/node", + hostPlatform, platforms: [ { platform: "ios", - available: platform === "ios" ? platformInstalled : otherPlatformInstalled, + available: + hostPlatform === "darwin" && + (platform === "ios" ? platformInstalled : otherPlatformInstalled), }, { platform: "android", @@ -297,6 +305,10 @@ it.effect.each(["ios", "android"] as const)( ), ); const ready = yield* host.ensureReady(() => Effect.void); + if (hostPlatform !== "darwin") { + platformInstalled = false; + yield* host.inspect; + } const platformNames = ready.devices.list.pipe( Effect.map((list) => list.devices diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index 6ea1bb34c19b2..b554659782fcf 100644 --- a/apps/server/src/device/SshDeviceHost.ts +++ b/apps/server/src/device/SshDeviceHost.ts @@ -35,6 +35,7 @@ const Probe = Schema.Struct({ }); const Started = Schema.Struct({ ...Probe.fields, + hostPlatform: Schema.String, hubPort: Schema.Int, daemonPort: Schema.optionalKey(Schema.Int), token: Schema.optionalKey(Schema.String), @@ -327,7 +328,10 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( summary.platforms.some( (candidate) => candidate.platform === platform && candidate.available, ); - if (!isAvailable("ios") || !isAvailable("android")) { + if ( + !isAvailable("android") || + (remote.hostPlatform === "darwin" && !isAvailable("ios")) + ) { const refreshed = yield* provide(probe(config, owner)).pipe(Effect.result); if (refreshed._tag === "Success") { summary = { ...summary, platforms: refreshed.success.platforms }; diff --git a/apps/server/src/device/sshDeviceScript.ts b/apps/server/src/device/sshDeviceScript.ts index 2e3e895293fa3..7a9b64c86f0d6 100644 --- a/apps/server/src/device/sshDeviceScript.ts +++ b/apps/server/src/device/sshDeviceScript.ts @@ -239,7 +239,7 @@ async function install(name, version, entry) { const vendor = path.resolve(path.dirname(hubEntry), '../../vendor/serve-sim/dist'); const optional = file => fs.existsSync(file) ? file : null; await pruneTools(path.join(root, 'tools'), [['expo-device-hub', hubVersion], ...(mode === 'agent-start' ? [['agent-device', agentVersion]] : [])], true).catch(() => {}); - console.log(JSON.stringify({ nodePath: process.execPath, platforms, tools: versions(), hubPort: hub.port, ...agentResult, + console.log(JSON.stringify({ nodePath: process.execPath, hostPlatform: process.platform, platforms, tools: versions(), hubPort: hub.port, ...agentResult, helpers: { serveSimAxSettings: optional(path.join(vendor, 'simax/serve-sim-ax-settings')), serveSimCli: optional(path.join(vendor, 'serve-sim.js')) } })); } finally { releaseHost(); } })().catch(error => { console.error(error.message); process.exitCode = 1; }); From 63c116abe5c26616bb1851515a58c958de2b9f99 Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:49:20 -0500 Subject: [PATCH 4/5] test(server): include host OS in SSH reconnect fixture --- apps/server/src/device/SshDeviceHost.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/device/SshDeviceHost.test.ts b/apps/server/src/device/SshDeviceHost.test.ts index fed5294bf4ec8..cf5bf4984ede0 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -381,6 +381,7 @@ it.effect( if (forwarding && ++forwardSpawns > 1) hubAnswers = true; const output = JSON.stringify({ nodePath: "/node", + hostPlatform: "darwin", platforms: [{ platform: "ios", available: true }], hubPort: 1234, helpers: { serveSimAxSettings: null, serveSimCli: null }, From 7e1620c098312aba65b392cacda01580ca0b5683 Mon Sep 17 00:00:00 2001 From: Sree Raman <1271305+sreejithraman@users.noreply.github.com> Date: Wed, 4 Nov 2026 19:54:29 -0500 Subject: [PATCH 5/5] fix(server): verify local iOS simulator tooling --- .../server/src/device/LocalDeviceHost.test.ts | 24 +++++++++++++++---- apps/server/src/device/LocalDeviceHost.ts | 23 ++++++++++++++++-- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index 6b86a4d032610..500b43d1b062f 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -284,6 +284,9 @@ const diagnose = ( platform: NodeJS.Platform = "darwin", ) => LocalDeviceHost.__testing.platformReason("android").pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: () => Effect.die("Android SDK availability must not run commands"), + }), Effect.provideService(HostProcess.Environment, environment), Effect.provideService(HostProcess.Platform, platform), Effect.provideService( @@ -517,7 +520,7 @@ it.effect.each([ }), ); -it.effect("keeps local Android and iOS available without ffmpeg", () => +it.live("requires usable iOS tools while keeping Android available without ffmpeg", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-ffmpeg-" }); @@ -548,9 +551,7 @@ it.effect("keeps local Android and iOS available without ffmpeg", () => HttpClient.HttpClient, HttpClient.make(() => Effect.die(new Error("Readiness must not make network requests"))), ), - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => Effect.die("Platform availability must not probe emulator video"), - }), + Effect.provide(ProcessRunner.layer), ); expect((yield* onTestHost(host.summary)).platforms).toEqual([ { platform: "ios", available: true }, @@ -564,6 +565,21 @@ it.effect("keeps local Android and iOS available without ffmpeg", () => platform: "ios", available: true, }); + yield* fs.writeFileString(`${baseDir}/bin/xcrun`, "#!/bin/sh\nexit 1\n"); + expect((yield* onTestHost(host.summary)).platforms).toEqual([ + { + platform: "ios", + available: false, + reason: + "iOS Simulator tools were not found. Install Xcode and select it with xcode-select.", + }, + { platform: "android", available: true }, + ]); + yield* fs.writeFileString(`${baseDir}/bin/xcrun`, "#!/bin/sh\nexit 0\n"); + expect(yield* onTestHost(host.platformAvailability("ios"))).toEqual({ + platform: "ios", + available: true, + }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index 8172a4ce3ba6f..66367cde0e4f4 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -102,12 +102,30 @@ interface RunningHost { const platformReason = Effect.fn("LocalDeviceHost.platformReason")(function* ( platform: DevicePlatform, -): Effect.fn.Return { +): Effect.fn.Return< + string | null, + never, + FileSystem.FileSystem | Path.Path | ProcessRunner.ProcessRunner +> { const hostPlatform = yield* HostProcess.Platform; if (platform === "ios") { if (hostPlatform !== "darwin") return "iOS Simulators need macOS with Xcode."; if (!(yield* isCommandAvailable("xcrun"))) return "Xcode command line tools were not found."; - return null; + const runner = yield* ProcessRunner.ProcessRunner; + const usable = yield* runner + .run({ + command: "xcrun", + args: ["simctl", "help"], + env: yield* HostProcess.Environment, + timeout: "5 seconds", + }) + .pipe( + Effect.map((result) => result.code === 0 && !result.timedOut), + Effect.orElseSucceed(() => false), + ); + return usable + ? null + : "iOS Simulator tools were not found. Install Xcode and select it with xcode-select."; } const sdk = yield* androidSdk; if (!sdk.root) @@ -281,6 +299,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const reason = yield* platformReason(platform).pipe( Effect.provideService(FileSystem.FileSystem, fs), Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), ); return reason === null ? { platform, available: true } : { platform, available: false, reason }; });