Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
),
);
61 changes: 37 additions & 24 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>) =>
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) =>
Expand Down
Loading