Skip to content
126 changes: 126 additions & 0 deletions apps/server/src/device/DeviceHubOperations.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}),
);
59 changes: 52 additions & 7 deletions apps/server/src/device/DeviceHubOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<boolean>;
readonly availablePlatforms: Effect.Effect<{ readonly ios: boolean; readonly android: boolean }>;
}): DeviceOperations => {
const { hostId, origin, run } = options;
const httpClient = options.http.pipe(HttpClient.withScope);
Expand Down Expand Up @@ -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<HostDevice> = [...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 ") &&
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/device/DeviceMultiHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
18 changes: 10 additions & 8 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -104,7 +104,7 @@ const fixture = Effect.fn("fixture")(function* (
const devices: Array<Omit<DeviceSummary, "hostId">> = [];
const discoveryErrors = yield* Ref.make<Array<{ message: string; error?: string }>>([]);
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<void>() };
let booted = false;
Expand Down Expand Up @@ -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 } : {}),
},
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 20 additions & 4 deletions apps/server/src/device/LocalDeviceHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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-" });
Expand Down Expand Up @@ -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 },
Expand All @@ -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)),
);

Expand Down
Loading
Loading