Open History to see the last commit
1import { describe, expect, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import {
4 DeviceHostUnavailableError,
5 DeviceId,
6 EnvironmentId,
7 ProviderInstanceId,
8 ThreadId,
9} from "@t3tools/contracts";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";
13import { McpSchema, McpServer } from "effect/ai";
14
15import * as ServerConfig from "../config.ts";
16import * as DeviceService from "../device/DeviceService.ts";
17import * as ProjectService from "../project/ProjectService.ts";
18import * as McpHttpServer from "./McpHttpServer.ts";
19import * as McpInvocationContext from "./McpInvocationContext.ts";
20import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts";
21
22const environmentId = EnvironmentId.make("environment-device-test");
23const threadId = ThreadId.make("thread-device-test");
24const invocation = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) => ({
25 environmentId,
26 requestNamespace: "provider-session-device-test",
27 thread: {
28 threadId,
29 providerSessionId: "provider-session-device-test",
30 providerInstanceId: ProviderInstanceId.make("codex"),
31 },
32 client: undefined,
33 capabilities: new Set(capabilities),
34 issuedAt: 1,
35});
36const client = McpSchema.McpServerClient.of({
37 clientId: 1,
38 clientCapabilities: {},
39 clientInfo: { name: "mcp-test", version: "1.0.0" },
40 protocolVersion: "2025-06-18",
41 initializePayload: {
42 protocolVersion: "2025-06-18",
43 capabilities: {},
44 clientInfo: { name: "mcp-test", version: "1.0.0" },
45 },
46 getClient: Effect.die("unused"),
47});
48
49const device = {
50 hostId: "local",
51 id: "UDID-1",
52 platform: "ios" as const,
53 name: "iPhone 17 Pro",
54 version: "iOS 27.0",
55 booted: true,
56 physical: false,
57};
58const state = {
59 hosts: [
60 {
61 id: "local",
62 kind: "local" as const,
63 label: "This machine",
64 platforms: [
65 { platform: "ios" as const, available: true },
66 { platform: "android" as const, available: false, reason: "No SDK" },
67 ],
68 hubInstalled: true,
69 agentDeviceInstalled: true,
70 },
71 ],
72 hostStatus: "ready" as const,
73 hostStatuses: { local: { status: "ready" as const } },
74 devices: [device],
75 sessions: [],
76 onboardingCompleted: true,
77 agentAccessEnabled: true,
78 hubBasePath: "/api/device-hub",
79 revision: 1,
80};
81const png = new Uint8Array(24);
82new DataView(png.buffer).setUint32(0, 0x89504e47);
83new DataView(png.buffer).setUint32(4, 0x0d0a1a0a);
84new DataView(png.buffer).setUint32(12, 0x49484452);
85new DataView(png.buffer).setUint32(16, 1206);
86new DataView(png.buffer).setUint32(20, 2622);
87
88const unused = Effect.die("not used");
89const readiness = {
90 hostId: "local",
91 nodePath: process.execPath,
92 hub: { origin: "http://hub.test" },
93 devices: {
94 list: unused,
95 boot: () => unused,
96 attach: () => unused,
97 shutdown: () => unused,
98 screenshot: () => unused,
99 },
100 run: () => unused,
101 helpers: { serveSimAxSettings: null, serveSimCli: null },
102 agentDevice: null,
103};
104const layerProjectsMock = Layer.mock(ProjectService.ProjectService)({
105 getById: () => Effect.succeedNone,
106});
107
108const openSession = {
109 threadId,
110 hostId: "local",
111 deviceId: DeviceId.make("UDID-1"),
112 platform: "ios" as const,
113 openedAt: "2026-09-08T00:00:00.000Z",
114};
115const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({
116 state: Effect.succeed(state),
117 list: Effect.succeed(state),
118 agentReadinessIfSupported: () => Effect.succeed(readiness),
119 open: (input) =>
120 Effect.succeed({
121 threadId: input.threadId,
122 hostId: "local",
123 deviceId: input.deviceId,
124 platform: input.platform,
125 openedAt: "2026-09-08T00:00:00.000Z",
126 }),
127
128 sessionsForThread: (id) => Effect.succeed(id === threadId ? [openSession] : []),
129 locateSession: (input) =>
130 Effect.succeed(
131 input.threadId === threadId && input.deviceId === openSession.deviceId
132 ? Option.some(openSession)
133 : Option.none(),
134 ),
135 screenshot: () => Effect.succeed({ device, png }),
136 close: () => Effect.void,
137 testHost: () => Effect.die("not used"),
138 // As for an EAS device, agent-device knows the device by a different id than T3.
139 agentTarget: () =>
140 Effect.succeed({
141 agentDeviceId: "SIM-UDID",
142 session: "thread-device",
143 args: ["--config", "/host.json", "--session", "thread-device"],
144 }),
145 agentLauncher: ({ targetArgs }) =>
146 Effect.succeedSome(`/state/device/targets/thread-device/${targetArgs.join("_")}/agent-device`),
147});
148
149const layerTest = McpHttpServer.layerDeviceToolkit.pipe(
150 Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
151 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
152 Layer.provideMerge(layerDeviceServiceMock),
153 Layer.provide(layerProjectsMock),
154 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" })),
155 Layer.provide(NodeServices.layer),
156);
157
158it.effect("registers the device tools and returns the screenshot as image content", () =>
159 Effect.scoped(
160 Effect.gen(function* () {
161 const server = yield* McpServer.McpServer;
162 const names = server.tools.map(({ tool }) => tool.name).toSorted();
163 expect(names).toEqual(["device_close", "device_list", "device_open", "device_screenshot"]);
164
165 const callWith = (capabilities: ReadonlyArray<McpInvocationContext.McpCapability>) =>
166 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(capabilities));
167
168 const opened = yield* server
169 .callTool({ name: "device_open", arguments: { platform: "ios" } })
170 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
171 expect(opened.isError).toBe(false);
172 const openedContent = opened.structuredContent as {
173 agentDevice: { command: string; targetArgs: ReadonlyArray<string>; session: string };
174 quickStart: string;
175 };
176 // Callers that pass the target flags, like test-t3-mobile's pair-client.sh, keep working.
177 expect(openedContent.agentDevice).toMatchObject({
178 targetArgs: [
179 "--platform",
180 "ios",
181 "--udid",
182 "SIM-UDID",
183 "--config",
184 "/host.json",
185 "--session",
186 "thread-device",
187 ],
188 session: "thread-device",
189 });
190 expect(openedContent.quickStart).toContain(
191 `${openedContent.agentDevice.command} snapshot -i`,
192 );
193 // The launcher selects the device by agent-device's id for it, not T3's.
194 expect(openedContent.agentDevice.command).toBe(
195 "/state/device/targets/thread-device/--platform_ios_--udid_SIM-UDID_--config_/host.json_--session_thread-device/agent-device",
196 );
197
198 const shot = yield* server
199 .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-1" } })
200 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
201 expect(shot.isError).toBe(false);
202 expect(shot.content.map((entry) => entry.type)).toEqual(["text", "image"]);
203 expect(shot.structuredContent).toMatchObject({
204 screenshot: { mimeType: "image/png", width: 1206, height: 2622 },
205 });
206
207 const foreign = yield* server
208 .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-2" } })
209 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
210 expect(foreign.isError).toBe(true);
211 expect(foreign.content.map((entry) => entry.type)).toEqual(["text"]);
212
213 const listed = yield* server
214 .callTool({ name: "device_list", arguments: { hostId: "THIS MACHINE" } })
215 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
216 expect(listed.structuredContent).toMatchObject({
217 hosts: [{ id: "local" }],
218 devices: [device],
219 });
220
221 const unknownHost = yield* server
222 .callTool({ name: "device_screenshot", arguments: { hostId: "oliverMac" } })
223 .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client));
224 expect(unknownHost.isError).toBe(true);
225 expect(unknownHost.content).toEqual([
226 { type: "text", text: "No device host oliverMac. Known hosts: This machine (local)." },
227 ]);
228
229 const denied = yield* server
230 .callTool({ name: "device_list", arguments: {} })
231 .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client));
232 expect(denied.isError).toBe(true);
233 // The agent can't see Settings, and a running session keeps the capabilities it started with,
234 // so the error says where the user turns access on and when it applies.
235 expect(denied.content).toEqual([
236 {
237 type: "text",
238 text: "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.",
239 },
240 ]);
241 }),
242 ).pipe(Effect.provide(layerTest)),
243);
244
245it.effect("rejects unavailable agent access before booting or opening a device", () => {
246 const layerUnavailable = Layer.mock(DeviceService.DeviceService)({
247 list: Effect.succeed(state),
248 agentReadinessIfSupported: () =>
249 Effect.fail(
250 new DeviceHostUnavailableError({ hostId: "local", reason: "Agent access is disabled." }),
251 ),
252 open: () => Effect.die("Must not boot or register a device when agent access fails"),
253 });
254 return Effect.gen(function* () {
255 const server = yield* McpServer.McpServer;
256 const result = yield* server
257 .callTool({ name: "device_open", arguments: { platform: "ios" } })
258 .pipe(
259 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
260 Effect.provideService(McpSchema.McpServerClient, client),
261 );
262 expect(result.isError).toBe(true);
263 expect(result.content).toEqual(
264 expect.arrayContaining([
265 expect.objectContaining({
266 type: "text",
267 text: expect.stringContaining("Agent access is disabled."),
268 }),
269 ]),
270 );
271 }).pipe(
272 Effect.scoped,
273 Effect.provide(
274 McpHttpServer.layerDeviceToolkit.pipe(
275 Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
276 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
277 Layer.provide(layerUnavailable),
278 Layer.provide(layerProjectsMock),
279 Layer.provide(
280 ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }),
281 ),
282 Layer.provide(NodeServices.layer),
283 ),
284 ),
285 );
286});
287
288it.effect("device_open reports a failed host's setup error instead of no devices", () => {
289 const failure = "Device support failed: npm was not found. Install Node.js, which includes npm.";
290 const failedState = {
291 ...state,
292 hostStatus: "failed" as const,
293 hostStatusDetail: failure,
294 hostStatuses: { local: { status: "failed" as const, detail: failure } },
295 devices: [],
296 };
297 const layerFailed = Layer.mock(DeviceService.DeviceService)({
298 state: Effect.succeed(failedState),
299 list: Effect.succeed(failedState),
300 locateDevice: () => Effect.succeedNone,
301 open: () => Effect.die("Must not open a device on a host whose setup failed"),
302 });
303 return Effect.gen(function* () {
304 const server = yield* McpServer.McpServer;
305 for (const args of [
306 { platform: "android" },
307 { deviceId: "Pixel_API_35", hostId: "local" },
308 { deviceId: "Pixel_API_35" },
309 ]) {
310 const result = yield* server
311 .callTool({ name: "device_open", arguments: args })
312 .pipe(
313 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
314 Effect.provideService(McpSchema.McpServerClient, client),
315 );
316 const text = result.content
317 .flatMap((entry) => (entry.type === "text" ? [entry.text] : []))
318 .join("\n");
319 expect(result.isError).toBe(true);
320 expect(text).not.toContain("No android devices were found");
321 expect(text).toContain(failure);
322 }
323 }).pipe(
324 Effect.scoped,
325 Effect.provide(
326 McpHttpServer.layerDeviceToolkit.pipe(
327 Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
328 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
329 Layer.provide(layerFailed),
330 Layer.provide(layerProjectsMock),
331 Layer.provide(
332 ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }),
333 ),
334 Layer.provide(NodeServices.layer),
335 ),
336 ),
337 );
338});
339
340describe("device targeting across hosts", () => {
341 const macHost = {
342 id: "mac-mini",
343 kind: "ssh" as const,
344 label: "Mac Mini",
345 platforms: [
346 { platform: "ios" as const, available: true },
347 { platform: "android" as const, available: true },
348 ],
349 hubInstalled: true,
350 agentDeviceInstalled: true,
351 };
352 const macIphone = { ...device, hostId: "mac-mini", id: "MAC-UDID" };
353 const localPixel = {
354 hostId: "local",
355 id: "Pixel_10",
356 platform: "android" as const,
357 name: "Pixel 10",
358 version: "Android 16",
359 booted: false,
360 physical: false,
361 };
362 const macPixel = { ...localPixel, hostId: "mac-mini" };
363 const multiHostState = {
364 ...state,
365 hosts: [...state.hosts, macHost],
366 hostStatuses: {
367 local: { status: "ready" as const },
368 "mac-mini": { status: "ready" as const },
369 },
370 devices: [device, macIphone, localPixel, macPixel],
371 };
372 const session = (hostId: string, deviceId: string, platform: "ios" | "android") => ({
373 threadId,
374 hostId,
375 deviceId: DeviceId.make(deviceId),
376 platform,
377 openedAt: "2026-09-08T00:00:00.000Z",
378 });
379 const thisMachine = { id: "local", label: "This machine" };
380 const macMini = { id: macHost.id, label: macHost.label };
381 // DeviceService's lookup errors; DeviceService.test.ts covers when each arises.
382 const onTwoHosts = new DeviceService.DeviceHostAmbiguousError({
383 deviceId: DeviceId.make("Pixel_10"),
384 hosts: [thisMachine, macMini],
385 });
386 const onTheMac = new DeviceService.DeviceOnOtherHostError({
387 deviceId: DeviceId.make("MAC-UDID"),
388 host: thisMachine,
389 hosts: [macMini],
390 });
391
392 const run = <A, E>(
393 sessions: ReadonlyArray<ReturnType<typeof session>>,
394 program: (calls: {
395 readonly opened: Array<{ hostId: string; deviceId: string }>;
396 readonly closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }>;
397 readonly call: (
398 name: string,
399 args: Record<string, unknown>,
400 ) => Effect.Effect<McpSchema.CallToolResult, McpSchema.McpErrorBase, McpServer.McpServer>;
401 }) => Effect.Effect<A, E, McpServer.McpServer>,
402 overrides: Partial<DeviceService.DeviceService["Service"]> = {},
403 ) => {
404 const opened: Array<{ hostId: string; deviceId: string }> = [];
405 const closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }> = [];
406 const layerDevices = Layer.mock(DeviceService.DeviceService)({
407 state: Effect.succeed(multiHostState),
408 list: Effect.succeed(multiHostState),
409 agentReadinessIfSupported: () => Effect.succeed(readiness),
410 open: (input) =>
411 Effect.sync(() => {
412 opened.push({ hostId: input.hostId ?? "local", deviceId: input.deviceId });
413 return session(input.hostId ?? "local", input.deviceId, input.platform);
414 }),
415 sessionsForThread: () => Effect.succeed(sessions),
416 locateDevice: (input) =>
417 Effect.succeed(
418 Option.fromUndefinedOr(
419 multiHostState.devices.find(
420 (candidate) =>
421 candidate.id === input.deviceId &&
422 (input.hostId === undefined || candidate.hostId === input.hostId),
423 ),
424 ),
425 ),
426 locateSession: (input) =>
427 Effect.succeed(
428 Option.fromUndefinedOr(
429 sessions.find(
430 (candidate) =>
431 candidate.deviceId === input.deviceId &&
432 (input.hostId === undefined || candidate.hostId === input.hostId),
433 ),
434 ),
435 ),
436 screenshot: (target) =>
437 Effect.succeed({
438 device:
439 multiHostState.devices.find(
440 (candidate) => candidate.hostId === target.hostId && candidate.id === target.deviceId,
441 ) ?? device,
442 png,
443 }),
444 close: (input) =>
445 Effect.sync(() => {
446 closed.push({ hostId: input.hostId, deviceId: input.deviceId });
447 }),
448 agentTarget: () =>
449 Effect.succeed({
450 agentDeviceId: "MAC-UDID",
451 session: "thread-device",
452 args: ["--config", "/mac.json", "--session", "thread-device"],
453 }),
454 agentLauncher: () => Effect.succeedSome("/state/device/targets/thread-device/agent-device"),
455 ...overrides,
456 });
457 const call = (name: string, args: Record<string, unknown>) =>
458 McpServer.McpServer.pipe(
459 Effect.flatMap((server) => server.callTool({ name, arguments: args })),
460 Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])),
461 Effect.provideService(McpSchema.McpServerClient, client),
462 );
463 return program({ opened, closed, call }).pipe(
464 Effect.scoped,
465 Effect.provide(
466 McpHttpServer.layerDeviceToolkit.pipe(
467 Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
468 Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer),
469 Layer.provide(layerDevices),
470 Layer.provide(layerProjectsMock),
471 Layer.provide(
472 ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }),
473 ),
474 Layer.provide(NodeServices.layer),
475 ),
476 ),
477 );
478 };
479 const text = (result: McpSchema.CallToolResult) =>
480 result.content.flatMap((entry) => (entry.type === "text" ? [entry.text] : [])).join("\n");
481
482 it.effect("opens a device id that exists only on another host", () =>
483 run([], ({ opened, call }) =>
484 Effect.gen(function* () {
485 const result = yield* call("device_open", { deviceId: "MAC-UDID" });
486 const byLabel = yield* call("device_open", { platform: "ios", hostId: "MAC MINI" });
487 expect(byLabel.isError).toBe(false);
488 expect(text(result)).not.toContain("No device");
489 expect(result.isError).toBe(false);
490 expect(opened).toEqual([
491 { hostId: "mac-mini", deviceId: "MAC-UDID" },
492 { hostId: "mac-mini", deviceId: "MAC-UDID" },
493 ]);
494 }),
495 ),
496 );
497
498 it.effect("binds the launcher to the id the device booted with", () => {
499 const targets: Array<string> = [];
500 return run(
501 [],
502 ({ call }) =>
503 Effect.gen(function* () {
504 const result = yield* call("device_open", { deviceId: "Pixel_10", hostId: "mac-mini" });
505 expect(result.isError).toBe(false);
506 expect(targets).toEqual(["emulator-5554", "emulator-5554"]);
507 }),
508 {
509 open: (input) =>
510 Effect.succeed(session(input.hostId ?? "local", "emulator-5554", "android")),
511 agentTarget: (target) =>
512 Effect.sync(() => {
513 targets.push(target.deviceId);
514 return { agentDeviceId: target.deviceId, session: "thread-device", args: [] };
515 }),
516 agentLauncher: (target) =>
517 Effect.sync(() => {
518 targets.push(target.deviceId);
519 return Option.some("/state/device/targets/thread-device/agent-device");
520 }),
521 },
522 );
523 });
524
525 it.effect("says a device closed during device_open was closed, not that its host is down", () =>
526 run(
527 [],
528 ({ call }) =>
529 Effect.gen(function* () {
530 const result = yield* call("device_open", { deviceId: "MAC-UDID" });
531 expect(result.isError).toBe(true);
532 expect(text(result)).toBe(
533 "The device was closed while device_open was preparing it. Call device_open again only if you still need it.",
534 );
535 }),
536 { agentLauncher: () => Effect.succeedNone },
537 ),
538 );
539
540 it.effect("refuses a device id that exists on two hosts and names both", () =>
541 run(
542 [],
543 ({ opened, call }) =>
544 Effect.gen(function* () {
545 const result = yield* call("device_open", { deviceId: "Pixel_10" });
546 expect(result.isError).toBe(true);
547 expect(text(result)).toContain("hostId local");
548 expect(text(result)).toContain("hostId mac-mini");
549 expect(text(result)).toBe(
550 "Device Pixel_10 is on more than one host: 'This machine' (hostId local), 'Mac Mini' (hostId mac-mini). Pass hostId.",
551 );
552 expect(opened).toEqual([]);
553 }),
554 { locateDevice: () => Effect.fail(onTwoHosts) },
555 ),
556 );
557
558 it.effect("keeps an explicit hostId and says which host has the device", () =>
559 run(
560 [],
561 ({ opened, call }) =>
562 Effect.gen(function* () {
563 const result = yield* call("device_open", { deviceId: "MAC-UDID", hostId: "local" });
564 expect(result.isError).toBe(true);
565 expect(text(result)).toContain("hostId mac-mini");
566 expect(text(result)).toBe(
567 "Device MAC-UDID is not on 'This machine' (hostId local). It is on 'Mac Mini' (hostId mac-mini); pass that hostId.",
568 );
569 expect(opened).toEqual([]);
570 }),
571 { locateDevice: () => Effect.fail(onTheMac) },
572 ),
573 );
574
575 it.effect("says no host has a device id, naming the host it was asked on", () =>
576 run([], ({ opened, call }) =>
577 Effect.gen(function* () {
578 const missing = (args: Record<string, unknown>) =>
579 call("device_open", { deviceId: "Pixel_9", ...args }).pipe(Effect.map(text));
580 expect(yield* missing({})).toBe(
581 "No device Pixel_9 on any host. Call device_list for current ids.",
582 );
583 expect(yield* missing({ hostId: "mac-mini" })).toBe(
584 "No device Pixel_9 on 'Mac Mini' (hostId mac-mini). Call device_list for current ids.",
585 );
586 expect(yield* missing({ hostId: "unknown-host" })).toBe(
587 "No device host unknown-host. Known hosts: This machine (local), Mac Mini (mac-mini).",
588 );
589 expect(opened).toEqual([]);
590 }),
591 ),
592 );
593
594 it.effect("screenshots an open device by id on whichever host it is on", () =>
595 run([session("mac-mini", "MAC-UDID", "ios")], ({ call }) =>
596 Effect.gen(function* () {
597 const result = yield* call("device_screenshot", { deviceId: "MAC-UDID" });
598 expect(result.isError).toBe(false);
599 expect(result.structuredContent).toMatchObject({ device: { hostId: "mac-mini" } });
600 const byLabel = yield* call("device_screenshot", { hostId: "MAC MINI" });
601 expect(byLabel.isError).toBe(false);
602 expect(byLabel.structuredContent).toMatchObject({ device: { hostId: "mac-mini" } });
603 }),
604 ),
605 );
606
607 it.effect("says why device_screenshot failed instead of a generic error", () =>
608 run(
609 [session("mac-mini", "MAC-UDID", "ios")],
610 ({ call }) =>
611 Effect.gen(function* () {
612 const wrongHost = yield* call("device_screenshot", {
613 deviceId: "MAC-UDID",
614 hostId: "local",
615 });
616 expect(wrongHost.isError).toBe(true);
617 expect(text(wrongHost)).toContain("It is on 'Mac Mini' (hostId mac-mini)");
618 expect(text(wrongHost)).toBe(
619 "Device MAC-UDID is not on 'This machine' (hostId local). It is on 'Mac Mini' (hostId mac-mini); pass that hostId.",
620 );
621 }),
622 { locateSession: () => Effect.fail(onTheMac) },
623 ),
624 );
625
626 it.effect("names a device_screenshot argument the tool does not declare", () =>
627 run([session("mac-mini", "MAC-UDID", "ios")], ({ call }) =>
628 Effect.gen(function* () {
629 const result = yield* call("device_screenshot", {
630 deviceId: "MAC-UDID",
631 includeImage: false,
632 });
633 expect(result.isError).toBe(true);
634 expect(text(result)).toContain('Unknown argument "includeImage"');
635 }),
636 ),
637 );
638
639 it.effect("tells an agent to open a device id before screenshotting it", () =>
640 run([], ({ call }) =>
641 Effect.gen(function* () {
642 const result = yield* call("device_screenshot", { deviceId: "MAC-UDID" });
643 expect(result.isError).toBe(true);
644 expect(text(result)).toBe(
645 "Device MAC-UDID is not open in this thread. Call device_open first.",
646 );
647 }),
648 ),
649 );
650
651 it.effect("tells an agent with no open device to call device_open before a screenshot", () =>
652 run([], ({ call }) =>
653 Effect.gen(function* () {
654 const result = yield* call("device_screenshot", {});
655 expect(result.isError).toBe(true);
656 expect(text(result)).toBe("No device is open in this thread. Call device_open first.");
657 expect(result.structuredContent).toMatchObject({
658 error: { _tag: "DeviceToolUnavailableError" },
659 });
660 }),
661 ),
662 );
663
664 it.effect("refuses to close an id open on two hosts instead of closing both", () =>
665 run(
666 [session("local", "Pixel_10", "android"), session("mac-mini", "Pixel_10", "android")],
667 ({ closed, call }) =>
668 Effect.gen(function* () {
669 const result = yield* call("device_close", { deviceId: "Pixel_10" });
670 expect(result.isError).toBe(true);
671 expect(text(result)).toContain("hostId mac-mini");
672 expect(text(result)).toBe(
673 "Device Pixel_10 is on more than one host: 'This machine' (hostId local), 'Mac Mini' (hostId mac-mini). Pass hostId.",
674 );
675 expect(closed).toEqual([]);
676 }),
677 { locateSession: () => Effect.fail(onTwoHosts) },
678 ),
679 );
680
681 it.effect("closes an open device on its host and treats an unopened one as closed", () =>
682 run([session("mac-mini", "MAC-UDID", "ios")], ({ closed, call }) =>
683 Effect.gen(function* () {
684 expect((yield* call("device_close", { deviceId: "MAC-UDID" })).isError).toBe(false);
685 expect((yield* call("device_close", { deviceId: "Pixel_9" })).isError).toBe(false);
686 expect((yield* call("device_close", { hostId: "MAC MINI" })).isError).toBe(false);
687 const unknown = yield* call("device_close", { hostId: "unknown-host" });
688 expect(unknown.isError).toBe(true);
689 expect(text(unknown)).toContain("No device host unknown-host.");
690 expect(closed).toEqual([
691 { hostId: "mac-mini", deviceId: "MAC-UDID" },
692 { hostId: undefined, deviceId: "Pixel_9" },
693 { hostId: "mac-mini", deviceId: undefined },
694 ]);
695 }),
696 ),
697 );
698});