Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
112 changes: 107 additions & 5 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
DeviceBootError,
DeviceId,
DeviceOperationError,
LOCAL_DEVICE_HOST_ID,
Expand Down Expand Up @@ -37,6 +38,8 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
import * as DeviceService from "./DeviceService.ts";

const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError));
const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError));

const baseState: DeviceServiceState = {
hosts: [],
Expand Down Expand Up @@ -77,7 +80,7 @@ describe("DeviceService.stateStream", () => {

const fixture = Effect.fn("fixture")(function* (
onBoot: Effect.Effect<void> = Effect.void,
bootError?: string,
bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> },
failListAfterShutdown = false,
runtimeFailure?:
| NodeRuntimeUnavailableError
Expand All @@ -92,6 +95,7 @@ const fixture = Effect.fn("fixture")(function* (
const agentStops: string[] = [];
const requests: string[] = [];
const devices: Array<Omit<DeviceSummary, "hostId">> = [];
const discoveryErrors = yield* Ref.make<Array<{ message: string; error?: string }>>([]);
const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" });
let booted = false;
let shutDown = false;
Expand Down Expand Up @@ -212,7 +216,12 @@ const fixture = Effect.fn("fixture")(function* (
return HttpClientResponse.fromWeb(
request,
Response.json(
bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" },
bootError
? {
ok: false,
...(typeof bootError === "string" ? { error: bootError } : bootError),
}
: { ok: true, serial: "emulator-5554" },
),
);
}
Expand All @@ -222,6 +231,7 @@ const fixture = Effect.fn("fixture")(function* (
return HttpClientResponse.fromWeb(
request,
Response.json({
errors: yield* Ref.get(discoveryErrors),
simulators: devices.filter((device) => device.platform === "ios"),
emulators: [
...devices.filter((device) => device.platform === "android"),
Expand All @@ -244,7 +254,17 @@ const fixture = Effect.fn("fixture")(function* (
),
),
);
return { service, starts, agentStarts, agentStops, requests, settings, devices, encoderResult };
return {
service,
starts,
agentStarts,
agentStops,
requests,
settings,
devices,
encoderResult,
discoveryErrors,
};
});

describe("device setup consent", () => {
Expand Down Expand Up @@ -506,6 +526,60 @@ describe("device discovery after server restart", () => {
);
});

const missingPlistMessage =
"[apple-utils] Failed to read CoreSimulator plist at /Users/test/Library/Developer/CoreSimulator/Devices/deleted-simulator/device.plist:";

it.effect.each([
{
message: missingPlistMessage,
error: "Error: ENOENT: no such file or directory, open 'device.plist'",
ignored: true,
},
{
message: missingPlistMessage,
error: "Error: EACCES: permission denied, open 'device.plist'",
ignored: false,
},
{ message: missingPlistMessage, error: undefined, ignored: false },
{
message: "[apple-utils] Failed to parse CoreSimulator device.plist:",
error: "Invalid XML",
ignored: false,
},
{
message: "[apple-utils] Failed to list devices:",
error: "Error: ENOENT: simctl missing",
ignored: false,
},
])("handles discovery diagnostic: $error", ({ message, error, ignored }) =>
Effect.gen(function* () {
const { service, discoveryErrors } = yield* fixture();
yield* Ref.set(discoveryErrors, [{ message, ...(error ? { error } : {}) }]);
const state = yield* service.configure({ enabled: true });
expect(state.hostStatus).toBe("ready");
expect(state.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]);
expect(state.hostStatusDetail).toBe(ignored ? undefined : message);
expect(state.hostStatuses.local?.detail).toBe(ignored ? undefined : message);
}).pipe(Effect.scoped),
);

it.effect("keeps other discovery warnings and clears them when only a deleted plist remains", () =>
Effect.gen(function* () {
const { service, discoveryErrors } = yield* fixture();
const missing = {
message: missingPlistMessage,
error: "Error: ENOENT: no such file or directory",
};
yield* Ref.set(discoveryErrors, [missing, { message: "Android discovery failed." }]);
const initial = yield* service.configure({ enabled: true });
expect(initial.hostStatusDetail).toBe("Android discovery failed.");
yield* Ref.set(discoveryErrors, [missing]);
const refreshed = yield* service.list;
expect(refreshed.hostStatusDetail).toBeUndefined();
expect(refreshed.hostStatuses.local?.detail).toBeUndefined();
}).pipe(Effect.scoped),
);

it.effect.each([
{
diagnostic: "Insufficient disk space at /private/user/path",
Expand All @@ -518,7 +592,8 @@ it.effect.each([
message: "did not become ready in time",
},
{
diagnostic: "Unexpected failure: secret-token",
diagnostic:
"The emulator process exited with code 1.\nTry manually: emulator -avd Pixel_API_35",
reason: "launch_failed",
message: "could not start",
},
Expand All @@ -535,7 +610,34 @@ it.effect.each([
.pipe(Effect.flip);
expect(error._tag).toBe("DeviceBootError");
expect(error.message).toContain(message);
expect(error.message).not.toContain(diagnostic);
expect(error.message).toContain(diagnostic);
expect((yield* service.state).bootingDevices).toEqual([]);
}).pipe(Effect.scoped),
);

it.effect("includes nested hub boot diagnostics and bounds them to 2,000 characters", () =>
Effect.gen(function* () {
const diagnostic = "Error: emulator binary missing\n" + "diagnostic output\n".repeat(200);
const { service } = yield* fixture(Effect.void, {
error: "Failed to spawn emulator",
errors: [{ message: "Failed to launch emulator binary", error: diagnostic }],
});
yield* service.configure({ enabled: true });
const error = yield* service
.open({
threadId: ThreadId.make("nested-boot-failure"),
deviceId: "Pixel_API_35",
platform: "android",
})
.pipe(Effect.flip);
expect(error._tag).toBe("DeviceBootError");
if (error._tag !== "DeviceBootError") return;
const expected = `Failed to spawn emulator\n\nFailed to launch emulator binary\n\n${diagnostic}`;
expect(error.diagnostics).toBe(`${expected.slice(0, 1_999)}…`);
expect(error.diagnostics).toHaveLength(2_000);
expect(error.message).toContain(error.diagnostics);
const roundTrip = decodeBootError(encodeBootError(error));
expect(roundTrip.message).toBe(error.message);
expect((yield* service.state).bootingDevices).toEqual([]);
}).pipe(Effect.scoped),
);
Expand Down
35 changes: 31 additions & 4 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,18 @@ const HubDevice = Schema.Struct({
const HubDeviceList = Schema.Struct({
simulators: Schema.Array(HubDevice),
emulators: Schema.Array(HubDevice),
errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))),
errors: Schema.optional(
Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })),
),
});
const HubActionResult = Schema.Struct({
ok: Schema.Boolean,
id: Schema.optional(Schema.String),
serial: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
errors: Schema.optional(
Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })),
),
});

export interface DeviceScreenshot {
Expand Down Expand Up @@ -439,7 +444,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
}
}
}
return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined };
const warnings = list.errors?.filter(
(error) =>
// CoreSimulator can retain deleted devices; their optional usage timestamps are absent.
!(
error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") &&
/\/CoreSimulator\/Devices\/[^/]+\/device\.plist:$/.test(error.message) &&
/(?:^Error:\s*ENOENT:|\bcode:\s*['"]ENOENT['"])/.test(error.error ?? "")
),
);
return { devices, detail: warnings?.map((error) => error.message).join("\n") || undefined };
});

const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
Expand Down Expand Up @@ -630,16 +644,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)),
);
if (!result.ok) {
// Keep the hub's launch command and nested failure available to agents for recovery.
const diagnostics = [
result.error,
...(result.errors ?? []).flatMap((error) => [error.message, error.error]),
]
.filter((text) => text?.trim())
.join("\n\n");
return yield* new DeviceBootError({
hostId: ready.hostId,
deviceId: device.id,
reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test(
result.error ?? "",
diagnostics,
)
? "disk_space"
: /timed? out|timeout/i.test(result.error ?? "")
: /timed? out|timeout/i.test(diagnostics)
? "timeout"
: "launch_failed",
...(diagnostics
? {
diagnostics:
diagnostics.length > 2_000 ? `${diagnostics.slice(0, 1_999)}…` : diagnostics,
}
: {}),
cause: result,
});
}
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/mcp/McpDeviceToolkit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
import {
DeviceBootError,
DeviceHostUnavailableError,
DeviceId,
EnvironmentId,
Expand Down Expand Up @@ -124,6 +125,45 @@ const layerTest = McpHttpServer.layerDeviceToolkit.pipe(
Layer.provide(NodeServices.layer),
);

it.effect("returns the complete bounded launch diagnostics to the agent", () => {
const diagnostics =
"Emulator exited with code 1.\nTry manually: emulator -avd TestPhone\n" + "x".repeat(1_930);
const failure = new DeviceBootError({
hostId: "local",
deviceId: "UDID-1",
reason: "launch_failed",
diagnostics: diagnostics.slice(0, 2_000),
cause: { ok: false, error: diagnostics },
});
const failedDevices = Layer.mock(DeviceService.DeviceService)({
list: Effect.succeed(state),
agentTarget: () => Effect.succeed([]),
open: () => Effect.fail(failure),
});
return Effect.gen(function* () {
const server = yield* McpServer.McpServer;
const result = yield* server
.callTool({ name: "device_open", arguments: { platform: "ios" } })
.pipe(
Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
Effect.provideService(McpSchema.McpServerClient, client),
);
expect(result.isError).toBe(true);
expect(result.content).toEqual([{ type: "text", text: failure.message }]);
}).pipe(
Effect.scoped,
Effect.provide(
McpHttpServer.layerDeviceToolkit.pipe(
Layer.provideMerge(McpServer.McpServer.layer),
Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
Layer.provide(failedDevices),
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-boot-error-test-" })),
Layer.provide(NodeServices.layer),
),
),
);
});

it.effect("registers the device tools and returns the screenshot as image content", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading