diff --git a/apps/server/src/device/DeviceHubOperations.test.ts b/apps/server/src/device/DeviceHubOperations.test.ts new file mode 100644 index 0000000000000..b0450fd77c024 --- /dev/null +++ b/apps/server/src/device/DeviceHubOperations.test.ts @@ -0,0 +1,126 @@ +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"), + availablePlatforms: Effect.succeed({ ios: true, android: 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"), + availablePlatforms: Effect.succeed({ ios: scenario !== "unavailable", android: 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 2725e7ac4fe29..687a3ed2ccc1f 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -35,6 +35,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), @@ -69,8 +79,7 @@ export const makeDeviceHubOperations = (options: { readonly origin: string; readonly http: HttpClient.HttpClient; readonly run: DeviceHostReady["run"]; - /** Android gates listing AVDs, which the hub does not report until they boot. */ - readonly platformAvailable: (platform: DevicePlatform) => 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); @@ -130,18 +139,20 @@ export const makeDeviceHubOperations = (options: { return { devices: [], detail: `The device hub did not answer within ${LIST_TIMEOUT_SECONDS} seconds.`, - incompletePlatforms: yield* Effect.filter(["ios", "android"] as const, (platform) => - options.platformAvailable(platform), + incompletePlatforms: yield* options.availablePlatforms.pipe( + Effect.map((available) => + (["ios", "android"] as const).filter((platform) => available[platform]), + ), ), }; } const hubList = listed.value; const devices: Array = [...hubList.simulators, ...hubList.emulators]; - const androidAvailable = yield* options.platformAvailable("android"); + 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 ") && @@ -154,8 +165,42 @@ export const makeDeviceHubOperations = (options: { const head = errorHead(error.error ?? ""); return boundDiagnostics(head ? `${error.message} ${head}` : error.message); }) ?? []; + 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( + 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) { + 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 adf01a5cda5d6..16f706b59a10f 100644 --- a/apps/server/src/device/DeviceMultiHost.test.ts +++ b/apps/server/src/device/DeviceMultiHost.test.ts @@ -52,7 +52,7 @@ it.effect("keeps hosts independent when serials collide and another host fails", origin: `http://${id}`, http, run, - platformAvailable: () => 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 64054630f0d9c..a7b266a2f65e1 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -60,7 +60,7 @@ it.effect.each([400, 500])( ), ), run: () => Effect.die("Screenshot does not run a command"), - platformAvailable: () => Effect.succeed(false), + availablePlatforms: Effect.succeed({ ios: false, android: false }), }); const result = yield* operations .screenshot({ @@ -104,7 +104,7 @@ const fixture = Effect.fn("fixture")(function* ( const devices: Array> = []; const discoveryErrors = yield* Ref.make>([]); const androidAvailable = yield* Ref.make(true); - const iosAvailable = yield* Ref.make(true); + const iosAvailable = yield* Ref.make(false); const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" }); const listing = { hangs: false, requested: yield* Deferred.make() }; let booted = false; @@ -213,8 +213,10 @@ const fixture = Effect.fn("fixture")(function* ( origin: "http://device.test", http, run, - platformAvailable: (platform) => - Ref.get(platform === "ios" ? iosAvailable : androidAvailable), + availablePlatforms: Effect.all({ + ios: Ref.get(iosAvailable), + android: Ref.get(androidAvailable), + }), }), ...(options.endsDevices ? { endsDevices: true } : {}), }, @@ -1070,7 +1072,7 @@ it.effect.each(["shutdown", "close"] as const)( origin: "http://device.test", http, run, - platformAvailable: () => Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, run, @@ -1232,7 +1234,7 @@ it.effect("leaves a session its host leases to every thread open when one thread origin: "http://device.test", http, run, - platformAvailable: () => Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), agentEndpoint: () => Effect.succeed({ @@ -1354,7 +1356,7 @@ it.effect.each([ origin: "http://device.test", http, run, - platformAvailable: () => Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, run, @@ -1426,7 +1428,7 @@ it.effect.each([LOCAL_DEVICE_HOST_ID, "ssh-fixture"])( origin: "http://device.test", http, run, - platformAvailable: () => Effect.succeed(true), + availablePlatforms: Effect.succeed({ ios: false, android: true }), }), helpers: { serveSimAxSettings: null, serveSimCli: null }, run, 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 6643e689328d1..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 }; }); @@ -823,8 +842,12 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { origin: running.hub.origin, http: httpClient, run, - platformAvailable: (platform) => - platformAvailability(platform).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 5bba5d084c543..bd7d412471bdf 100644 --- a/apps/server/src/device/SshDeviceHost.test.ts +++ b/apps/server/src/device/SshDeviceHost.test.ts @@ -74,6 +74,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 }, @@ -196,119 +197,172 @@ 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("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([ + { 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 = hostPlatform !== "darwin"; + let otherPlatformInstalled = true; + 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", + hostPlatform, + platforms: [ + { + platform: "ios", + available: + hostPlatform === "darwin" && + (platform === "ios" ? platformInstalled : otherPlatformInstalled), + }, + { + platform: "android", + available: + platform === "android" ? platformInstalled : otherPlatformInstalled, + }, + ], + 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); + if (hostPlatform !== "darwin") { + platformInstalled = false; + yield* host.inspect; + } + 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(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + platformInstalled = true; + yield* host.inspect; + 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))), ); it.effect( @@ -327,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 }, diff --git a/apps/server/src/device/SshDeviceHost.ts b/apps/server/src/device/SshDeviceHost.ts index d769a85ddb080..7dfa48fcab248 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, @@ -48,6 +49,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), @@ -379,15 +381,23 @@ export const make = Effect.fn("SshDeviceHost.make")(function* ( origin: hubOrigin, http, run, - // Keep probing negative reports so tooling installed after connecting is still found. - platformAvailable: (platform) => - Effect.suspend(() => - summary.platforms.some((p) => p.platform === platform && p.available) - ? Effect.succeed(true) - : platformAvailability(platform).pipe( - Effect.map((availability) => availability.available), - ), - ), + // Keep probing negative reports so an SDK installed after connecting is still found. + availablePlatforms: Effect.gen(function* () { + const isAvailable = (platform: DevicePlatform) => + summary.platforms.some( + (candidate) => candidate.platform === platform && candidate.available, + ); + 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 }; + } + } + return { ios: isAvailable("ios"), android: isAvailable("android") }; + }), }), ...(remote.daemonPort !== undefined && remote.token !== undefined && 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; });