diff --git a/packages/docs/docs/fundamentals/supported-platforms.mdx b/packages/docs/docs/fundamentals/supported-platforms.mdx index 779111c3bf..bd7e7df7c9 100644 --- a/packages/docs/docs/fundamentals/supported-platforms.mdx +++ b/packages/docs/docs/fundamentals/supported-platforms.mdx @@ -43,6 +43,17 @@ Argent drives these foldable devices closed, half-open and open: The agent folds and unfolds the device with the `fold` tool. `list-devices` marks a foldable device with `foldable: true`. Screenshots, element trees, taps and recordings use the panel that shows the UI. Argent cannot fold a physical Android device. See the [tools reference](/docs/reference/tools). +### Emulators started by another user + +Argent finds an emulator through a file that the emulator writes in the home directory of its user. An emulator that another user or another `HOME` started has no such file for Argent. Argent then controls that emulator over adb, the same way as a physical Android device. This path has these limits: + +- Two-finger gestures (pinch, rotate, a two-finger `gesture-custom`) fail with an error. On this path, Android keeps both fingers down after the gesture and refuses all later touches. +- Screenshots have a lower resolution than on an emulator that Argent finds. `screenshot-diff` reports a size mismatch when it compares screenshots from the two paths. +- Only one Argent tool-server can control the emulator. When a second tool-server connects, the first one loses its connection and connects again on its next call. +- Argent copies its screen-sharing agent to `/data/local/tmp/.studio` on the emulator and keeps it there for the next start. + +To remove these limits, start the emulator as the same user that runs Argent, or start it with `boot-device`. + ### Physical iPhones Argent also drives a physical iPhone over a USB cable. Physical iPads are not supported yet. Automation on hardware is scoped to one app at a time, and a subset of the simulator tools is available. See [Physical iOS devices](/docs/features/physical-ios-devices). diff --git a/packages/tool-server/src/blueprints/simulator-server.ts b/packages/tool-server/src/blueprints/simulator-server.ts index 72d620a4a4..4f4a3c1904 100644 --- a/packages/tool-server/src/blueprints/simulator-server.ts +++ b/packages/tool-server/src/blueprints/simulator-server.ts @@ -19,6 +19,12 @@ import { isFoldableSimulator, isTvOsSimulator } from "../utils/ios-devices"; import { deviceSetForUdid } from "../utils/ios-device-sets"; import { UnsupportedOperationError } from "../utils/capability"; import { openMoqClient } from "../utils/moq-client"; +import { openMjpegStream } from "../tools/screen-recording/mjpeg-stream"; +import { + readAutoRotate, + removeScreenSharingReverses, + restoreAutoRotate, +} from "../utils/android-device-controller-state"; import { createMoqTransport, fetchDisplayState, @@ -128,6 +134,13 @@ export interface SimulatorServerApi { * screen 1 as before. */ display?: SimulatorDisplayState; + /** + * Set for an emulator driven through the phone controller (see + * `spawnWithEmulatorFallback`). That controller lifts two fingers with one + * ACTION_UP that carries both pointers, which Android rejects: both pointers + * stay down and every later touch on the device is refused until it reboots. + */ + singleTouchOnly?: true; } /** Bound on the `GET /api/display` probe a foldable's server gets at attach time. */ @@ -279,7 +292,9 @@ const SAFE_SIMULATOR_DEVICE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; /** * iOS simulators, Android emulators and physical Android phones each get their * own simulator-server controller; the phone one (`android_device`, screen-sharing - * agent over adb) is picked by `kind`, not by platform alone. + * agent over adb) is picked by `kind`, not by platform alone. An emulator whose + * gRPC bridge cannot be discovered is later retried on `android_device` too + * (see `spawnWithEmulatorFallback`), while keeping `kind: "emulator"`. */ function subcommandForDevice(device: DeviceInfo): "ios" | "android" | "android_device" { if (device.platform === "ios") { @@ -298,6 +313,71 @@ function subcommandForDevice(device: DeviceInfo): "ios" | "android" | "android_d return device.kind === "device" ? "android_device" : "android"; } +/** + * The emulator controller finds an AVD's gRPC bridge through the emulator's + * discovery file (`avd/running/pid_*.ini` in a per-user directory: under the + * user's home or cache folder, or `$XDG_RUNTIME_DIR` / `/run/user/` on + * Linux). An emulator started outside argent by another user or HOME (a CI + * runner, a service account) has no such file where argent looks, so + * simulator-server exits with "not found among running emulators", or, on + * Linux when no discovery directory exists at all, "Failed to find any running + * emulator". The phone controller (screen-sharing agent over adb) needs + * nothing but adb, so such an emulator is retried through it instead of + * leaving the device with no screenshot, tap or swipe at all. + */ +const EMULATOR_NOT_DISCOVERED = + /not found among running emulators|Failed to find any running emulator/; + +const FIRST_FRAME_TIMEOUT_MS = 5_000; + +/** + * The phone controller prints its readiness lines before the agent injects + * input: a touch sent before the first frame is acked but never lands. + */ +async function waitForFirstFrame(streamUrl: string): Promise { + if (!streamUrl) return; + const stream = await openMjpegStream(streamUrl).catch(() => null); + if (!stream) return; + await stream + .waitForFirstFrame(FIRST_FRAME_TIMEOUT_MS) + .catch(() => {}) + .finally(() => stream.close()); +} + +type SpawnedServer = Awaited>; + +async function spawnWithEmulatorFallback( + device: DeviceInfo +): Promise { + const subcommand = subcommandForDevice(device); + try { + return { ...(await spawnSimulatorServerProcess(device.id, subcommand)), adbFallback: false }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (subcommand !== "android" || !EMULATOR_NOT_DISCOVERED.test(message)) throw err; + process.stderr.write( + `[sim ${device.id}] emulator not found through gRPC discovery; ` + + `falling back to the adb screen-sharing controller\n` + ); + await removeScreenSharingReverses(device.id).catch(() => {}); + try { + const server = await spawnSimulatorServerProcess(device.id, "android_device"); + await waitForFirstFrame(server.streamUrl); + return { ...server, adbFallback: true }; + } catch (fallbackErr) { + // "Failed to find any running emulator" also means no emulator is up at + // all; then the fallback fails too, and the emulator controller's error + // (and its failure signal) is the one that explains it. + const fallbackMessage = + fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr); + process.stderr.write( + `[sim ${device.id}] adb screen-sharing fallback failed too: ${fallbackMessage}\n` + ); + throw err; + } + } +} + async function spawnSimulatorServerProcess( udid: string, subcommand: "ios" | "android" | "android_device" @@ -567,10 +647,8 @@ export const simulatorServerBlueprint: ServiceBlueprint null) : null; const events = new TypedEventEmitter(); @@ -602,9 +680,11 @@ export const simulatorServerBlueprint: ServiceBlueprint { proc.kill(); + if (autoRotate !== null) await restoreAutoRotate(device.id, autoRotate).catch(() => {}); }, events, }; diff --git a/packages/tool-server/src/tools/gesture-custom/index.ts b/packages/tool-server/src/tools/gesture-custom/index.ts index eb1689895c..279f1514d2 100644 --- a/packages/tool-server/src/tools/gesture-custom/index.ts +++ b/packages/tool-server/src/tools/gesture-custom/index.ts @@ -12,7 +12,7 @@ import { import { isIosPhysicalDevice, resolveDevice } from "../../utils/device-info"; import { InvalidToolInputError } from "../../utils/capability"; import { sendCommand } from "../../utils/simulator-client"; -import { interpolateEvents } from "../../utils/gesture-utils"; +import { assertTwoFingerTouchSupported, interpolateEvents } from "../../utils/gesture-utils"; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -84,6 +84,10 @@ type IosDeviceGesturePlan = | { kind: "gesture"; down: GestureEvent; hold?: GestureEvent; up: GestureEvent } | { kind: "unsupported"; reason: string }; +function hasSecondTouch(event: GestureEvent): boolean { + return event.x2 !== undefined || event.y2 !== undefined; +} + function samePoint(a: GestureEvent, b: GestureEvent): boolean { return Math.abs(a.x - b.x) <= SAME_POINT_EPSILON && Math.abs(a.y - b.y) <= SAME_POINT_EPSILON; } @@ -99,7 +103,7 @@ function samePoint(a: GestureEvent, b: GestureEvent): boolean { * list item is picked up with a long press and dragged. */ function planIosDeviceGesture(events: Params["events"]): IosDeviceGesturePlan { - if (events.some((e) => e.x2 !== undefined || e.y2 !== undefined)) { + if (events.some(hasSecondTouch)) { return { kind: "unsupported", reason: @@ -226,6 +230,9 @@ Example pinch-to-zoom (with interpolate:10 for smoothness): ); } const api = services.simulatorServer as SimulatorServerApi; + if (params.events.some(hasSecondTouch)) { + assertTwoFingerTouchSupported(api, "gesture-custom", device); + } const events = params.interpolate && params.interpolate > 0 ? interpolateEvents(params.events, params.interpolate) diff --git a/packages/tool-server/src/tools/gesture-pinch/index.ts b/packages/tool-server/src/tools/gesture-pinch/index.ts index 5d2ba95722..973e885c5d 100644 --- a/packages/tool-server/src/tools/gesture-pinch/index.ts +++ b/packages/tool-server/src/tools/gesture-pinch/index.ts @@ -3,7 +3,7 @@ import type { ServiceRef, ToolCapability, ToolDefinition } from "@argent/registr import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; import { harmonyConnectKey, resolveDevice } from "../../utils/device-info"; import { ensureDep } from "../../utils/check-deps"; -import { sendTouchEvent } from "../../utils/gesture-utils"; +import { assertTwoFingerTouchSupported, sendTouchEvent } from "../../utils/gesture-utils"; import { HARMONY_INTERACTION_TIMEOUT_MS, assertHarmonyDisplayReady, @@ -180,6 +180,7 @@ Use when you need to zoom in or out on a map, image, or zoomable view. Returns { return { pinched: true, timestampMs }; } const api = services.simulatorServer as SimulatorServerApi; + assertTwoFingerTouchSupported(api, "gesture-pinch", device); const steps = Math.max(1, Math.round(duration / 16)); let timestampMs = 0; diff --git a/packages/tool-server/src/tools/gesture-rotate/index.ts b/packages/tool-server/src/tools/gesture-rotate/index.ts index d9845eea78..812fd7fbca 100644 --- a/packages/tool-server/src/tools/gesture-rotate/index.ts +++ b/packages/tool-server/src/tools/gesture-rotate/index.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { type ToolCapability, type ToolContext, type ToolDefinition } from "@argent/registry"; import { simulatorServerRef, type SimulatorServerApi } from "../../blueprints/simulator-server"; import { resolveDevice } from "../../utils/device-info"; -import { sendTouchEvent } from "../../utils/gesture-utils"; +import { assertTwoFingerTouchSupported, sendTouchEvent } from "../../utils/gesture-utils"; import { sleep } from "../../utils/timing"; const zodSchema = z @@ -103,6 +103,7 @@ Size the orbit with radius, or with radiusX and radiusY together (the pair overr }), async execute(services, params, ctx?: ToolContext) { const api = services.simulatorServer as SimulatorServerApi; + assertTwoFingerTouchSupported(api, "gesture-rotate", resolveDevice(params.udid)); const duration = params.durationMs ?? 300; const steps = Math.max(1, Math.round(duration / 16)); // Refines guarantee radius is set whenever the per-axis pair is absent. diff --git a/packages/tool-server/src/utils/android-device-controller-state.ts b/packages/tool-server/src/utils/android-device-controller-state.ts new file mode 100644 index 0000000000..4805504f20 --- /dev/null +++ b/packages/tool-server/src/utils/android-device-controller-state.ts @@ -0,0 +1,26 @@ +import { adbShell, runAdb } from "./adb"; + +const SCREEN_SHARING_REVERSE = /\blocalabstract:(screen-sharing-agent-\d+)\b/g; + +/** + * Each phone controller start adds an `adb reverse` for its agent socket and removes + * it on a clean exit, but a killed controller leaves it behind. A new start + * replaces every running agent anyway, so all of them are stale by then. + */ +export async function removeScreenSharingReverses(serial: string): Promise { + const { stdout } = await runAdb(["-s", serial, "reverse", "--list"]); + for (const [, socket] of stdout.matchAll(SCREEN_SHARING_REVERSE)) { + await runAdb(["-s", serial, "reverse", "--remove", `localabstract:${socket}`]); + } +} + +/** The phone controller's `rotate` turns auto-rotate off and never turns it back on. */ +export async function readAutoRotate(serial: string): Promise { + const value = (await adbShell(serial, "settings get system accelerometer_rotation")).trim(); + return value === "0" || value === "1" ? value : null; +} + +export async function restoreAutoRotate(serial: string, value: string): Promise { + if ((await readAutoRotate(serial)) === value) return; + await adbShell(serial, `settings put system accelerometer_rotation ${value}`); +} diff --git a/packages/tool-server/src/utils/device-info.ts b/packages/tool-server/src/utils/device-info.ts index d550d55fde..ccceb5d1e0 100644 --- a/packages/tool-server/src/utils/device-info.ts +++ b/packages/tool-server/src/utils/device-info.ts @@ -147,7 +147,9 @@ export function classifyDevice(udid: string): Platform { * * The distinction picks the simulator-server controller: emulators go through * the emulator gRPC bridge (`android` subcommand), physical devices through the - * screen-sharing agent over adb (`android_device`). + * screen-sharing agent over adb (`android_device`). An emulator whose gRPC + * bridge cannot be discovered (started by another user or HOME) falls back to + * `android_device` as well; see the simulator-server blueprint. */ export function isAndroidEmulatorSerial(serial: string): boolean { return serial.startsWith("emulator-"); diff --git a/packages/tool-server/src/utils/gesture-utils.ts b/packages/tool-server/src/utils/gesture-utils.ts index e5ad816282..9391466ca3 100644 --- a/packages/tool-server/src/utils/gesture-utils.ts +++ b/packages/tool-server/src/utils/gesture-utils.ts @@ -1,4 +1,6 @@ +import type { DeviceInfo } from "@argent/registry"; import type { SimulatorServerApi } from "../blueprints/simulator-server"; +import { UnsupportedOperationError } from "./capability"; import { sendCommand, type SendCommandOutcome } from "./simulator-client"; interface TouchPoint { @@ -61,6 +63,22 @@ export function interpolateEvents(events: TouchEvent[], steps: number): TouchEve return result; } +export function assertTwoFingerTouchSupported( + api: SimulatorServerApi, + toolId: string, + device: DeviceInfo +): void { + if (!api.singleTouchOnly) return; + throw new UnsupportedOperationError( + toolId, + device, + "Argent could not find this emulator's gRPC bridge, so it drives the emulator over adb " + + "like a physical phone. That path cannot lift two fingers: they would stay down and " + + "block every later touch. Start the emulator as the same user that runs Argent " + + "(or with boot-device) to use two-finger gestures" + ); +} + export function sendTouchEvent( api: SimulatorServerApi, type: "Down" | "Move" | "Up", diff --git a/packages/tool-server/test/android-device-controller-state.test.ts b/packages/tool-server/test/android-device-controller-state.test.ts new file mode 100644 index 0000000000..200fb5f6eb --- /dev/null +++ b/packages/tool-server/test/android-device-controller-state.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const runAdbMock = vi.fn(); +const adbShellMock = vi.fn(); +vi.mock("../src/utils/adb", () => ({ + runAdb: (args: string[]) => runAdbMock(args), + adbShell: (serial: string, command: string) => adbShellMock(serial, command), +})); + +import { + readAutoRotate, + removeScreenSharingReverses, + restoreAutoRotate, +} from "../src/utils/android-device-controller-state"; + +beforeEach(() => { + runAdbMock.mockReset(); + adbShellMock.mockReset(); +}); + +describe("removeScreenSharingReverses", () => { + it("removes every screen-sharing agent reverse and keeps the others", async () => { + runAdbMock.mockResolvedValueOnce({ + stdout: + "host-21 localabstract:screen-sharing-agent-54629 tcp:54629\n" + + "host-21 tcp:8081 tcp:8081\n" + + "host-21 localabstract:screen-sharing-agent-54716 tcp:54716\n", + stderr: "", + }); + runAdbMock.mockResolvedValue({ stdout: "", stderr: "" }); + + await removeScreenSharingReverses("emulator-5554"); + + expect(runAdbMock.mock.calls.map(([args]) => args)).toEqual([ + ["-s", "emulator-5554", "reverse", "--list"], + ["-s", "emulator-5554", "reverse", "--remove", "localabstract:screen-sharing-agent-54629"], + ["-s", "emulator-5554", "reverse", "--remove", "localabstract:screen-sharing-agent-54716"], + ]); + }); +}); + +describe("auto-rotate", () => { + it("reads the setting and ignores anything but 0 or 1", async () => { + adbShellMock.mockResolvedValueOnce("1\n").mockResolvedValueOnce("null\n"); + + expect(await readAutoRotate("emulator-5554")).toBe("1"); + expect(await readAutoRotate("emulator-5554")).toBeNull(); + }); + + it("writes the setting back only when the controller changed it", async () => { + adbShellMock.mockResolvedValueOnce("0\n").mockResolvedValueOnce(""); + await restoreAutoRotate("emulator-5554", "1"); + expect(adbShellMock).toHaveBeenLastCalledWith( + "emulator-5554", + "settings put system accelerometer_rotation 1" + ); + + adbShellMock.mockReset().mockResolvedValueOnce("1\n"); + await restoreAutoRotate("emulator-5554", "1"); + expect(adbShellMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/tool-server/test/gesture-two-finger-adb-fallback.test.ts b/packages/tool-server/test/gesture-two-finger-adb-fallback.test.ts new file mode 100644 index 0000000000..1a2188d9e8 --- /dev/null +++ b/packages/tool-server/test/gesture-two-finger-adb-fallback.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { getFailureSignal } from "@argent/registry"; + +vi.mock("../src/utils/simulator-client", async (importOriginal) => ({ + ...(await importOriginal()), + sendCommand: vi.fn(async () => ({})), +})); + +import { gesturePinchTool } from "../src/tools/gesture-pinch"; +import { gestureRotateTool } from "../src/tools/gesture-rotate"; +import { gestureCustomTool } from "../src/tools/gesture-custom"; +import { sendCommand } from "../src/utils/simulator-client"; + +const udid = "emulator-5570"; +const fallenBack = { simulatorServer: { singleTouchOnly: true } } as never; +const grpc = { simulatorServer: {} } as never; + +beforeEach(() => { + vi.mocked(sendCommand).mockClear(); +}); + +async function rejection(run: Promise): Promise { + return (await run.then( + () => { + throw new Error("expected a rejection"); + }, + (err: unknown) => err + )) as Error; +} + +describe("two-finger gestures on an emulator driven over the adb fallback", () => { + it.each([ + [ + "gesture-pinch", + () => + gesturePinchTool.execute(fallenBack, { + udid, + centerX: 0.5, + centerY: 0.5, + startDistance: 0.1, + endDistance: 0.5, + durationMs: 48, + }), + ], + [ + "gesture-rotate", + () => + gestureRotateTool.execute(fallenBack, { + udid, + centerX: 0.5, + centerY: 0.5, + radius: 0.1, + startAngle: 0, + endAngle: 90, + durationMs: 48, + }), + ], + [ + "gesture-custom", + () => + gestureCustomTool.execute(fallenBack, { + udid, + events: [ + { type: "Down", x: 0.4, y: 0.5, x2: 0.6, y2: 0.5 }, + { type: "Up", x: 0.2, y: 0.5, x2: 0.8, y2: 0.5 }, + ], + }), + ], + ])("%s is rejected before any touch is sent", async (_tool, run) => { + const error = await rejection(run()); + + expect(error.name).toBe("UnsupportedOperationError"); + expect(error.message).toContain("cannot lift two fingers"); + expect(getFailureSignal(error)?.error_code).toBe("TOOL_CAPABILITY_UNSUPPORTED_OPERATION"); + expect(sendCommand).not.toHaveBeenCalled(); + }); + + it("still runs a single-finger gesture-custom train", async () => { + await gestureCustomTool.execute(fallenBack, { + udid, + events: [ + { type: "Down", x: 0.5, y: 0.5, delayMs: 0 }, + { type: "Up", x: 0.5, y: 0.3, delayMs: 0 }, + ], + }); + + expect(sendCommand).toHaveBeenCalledTimes(2); + }); + + it("still pinches on the emulator controller", async () => { + await gesturePinchTool.execute(grpc, { + udid, + centerX: 0.5, + centerY: 0.5, + startDistance: 0.1, + endDistance: 0.5, + durationMs: 48, + }); + + expect(sendCommand).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/tool-server/test/simulator-server-blueprint.test.ts b/packages/tool-server/test/simulator-server-blueprint.test.ts index e6a96ea7c5..4120a6baab 100644 --- a/packages/tool-server/test/simulator-server-blueprint.test.ts +++ b/packages/tool-server/test/simulator-server-blueprint.test.ts @@ -61,6 +61,24 @@ vi.mock("../src/utils/ios-device-sets", () => ({ deviceSetForUdid: (udid: string) => deviceSetForUdidMock(udid), })); +const waitForFirstFrameMock = vi.fn(async (_timeoutMs: number) => Buffer.alloc(0)); +const openMjpegStreamMock = vi.fn(async (_url: string) => ({ + waitForFirstFrame: waitForFirstFrameMock, + close: vi.fn(), +})); +vi.mock("../src/tools/screen-recording/mjpeg-stream", () => ({ + openMjpegStream: (url: string) => openMjpegStreamMock(url), +})); + +const removeScreenSharingReversesMock = vi.fn(async (_serial: string) => {}); +const readAutoRotateMock = vi.fn(async (_serial: string): Promise => "1"); +const restoreAutoRotateMock = vi.fn(async (_serial: string, _value: string) => {}); +vi.mock("../src/utils/android-device-controller-state", () => ({ + removeScreenSharingReverses: (serial: string) => removeScreenSharingReversesMock(serial), + readAutoRotate: (serial: string) => readAutoRotateMock(serial), + restoreAutoRotate: (serial: string, value: string) => restoreAutoRotateMock(serial, value), +})); + function makeFakeProc() { const proc = new EventEmitter() as EventEmitter & { stdout: Readable; @@ -210,6 +228,122 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf }); }); + it.each([ + [ + "the discovery file is missing", + 'Error: emulator "emulator-5570" not found among running emulators (a token-less `-grpc` launch omits grpc.token; check avd/running/*.ini)\n', + ], + ["no discovery directory exists (Linux)", "Error: Failed to find any running emulator\n"], + ])("falls back to `android_device` when %s", async (_case, discoveryError) => { + // An emulator launched outside argent under another HOME has no discovery + // file where argent looks, so the gRPC controller cannot find it; the adb + // screen-sharing controller still drives it. + const grpcLess = makeFakeProc(); + const fallback = makeFakeProc(); + spawnMock.mockReturnValueOnce(grpcLess).mockReturnValueOnce(fallback); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const serial = "emulator-5570"; + const device = androidDevice(serial); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + setImmediate(() => { + grpcLess.stderr.push(discoveryError); + setTimeout(() => { + grpcLess.emit("exit", 1); + signalReady(fallback, 55570); + }, 20); + }); + await factoryPromise; + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]![1]).toEqual(["android", "--id", serial]); + expect(spawnMock.mock.calls[1]![1]).toEqual(["android_device", "--id", serial]); + expect(openMjpegStreamMock).toHaveBeenCalledWith("http://127.0.0.1:55571"); + expect(waitForFirstFrameMock).toHaveBeenCalledTimes(1); + }); + + it("marks a fallen-back emulator single-touch and undoes its auto-rotate change on dispose", async () => { + const grpcLess = makeFakeProc(); + const fallback = makeFakeProc(); + spawnMock.mockReturnValueOnce(grpcLess).mockReturnValueOnce(fallback); + removeScreenSharingReversesMock.mockClear(); + restoreAutoRotateMock.mockClear(); + readAutoRotateMock.mockClear().mockResolvedValueOnce("1"); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const serial = "emulator-5574"; + const device = androidDevice(serial); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + setImmediate(() => { + grpcLess.stderr.push(`Error: emulator "${serial}" not found among running emulators\n`); + setTimeout(() => { + grpcLess.emit("exit", 1); + signalReady(fallback, 55574); + }, 20); + }); + const instance = await factoryPromise; + + expect(removeScreenSharingReversesMock).toHaveBeenCalledWith(serial); + expect(instance.api.singleTouchOnly).toBe(true); + + await instance.dispose(); + expect(fallback.kill).toHaveBeenCalled(); + expect(restoreAutoRotateMock).toHaveBeenCalledWith(serial, "1"); + }); + + it("leaves an emulator found through gRPC discovery multi-touch and untouched", async () => { + const proc = makeFakeProc(); + spawnMock.mockReturnValueOnce(proc); + removeScreenSharingReversesMock.mockClear(); + readAutoRotateMock.mockClear(); + restoreAutoRotateMock.mockClear(); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const device = androidDevice("emulator-5576"); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + signalReady(proc, 55576); + const instance = await factoryPromise; + await instance.dispose(); + + expect(instance.api.singleTouchOnly).toBeUndefined(); + expect(removeScreenSharingReversesMock).not.toHaveBeenCalled(); + expect(readAutoRotateMock).not.toHaveBeenCalled(); + expect(restoreAutoRotateMock).not.toHaveBeenCalled(); + }); + + it("surfaces the emulator controller's error when the `android_device` fallback fails too", async () => { + // With no emulator running at all, both controllers fail; the original + // discovery error explains it better than the phone controller's. + const grpcLess = makeFakeProc(); + const fallback = makeFakeProc(); + spawnMock.mockReturnValueOnce(grpcLess).mockReturnValueOnce(fallback); + + const { simulatorServerBlueprint } = await import("../src/blueprints/simulator-server"); + + const device = androidDevice("emulator-5572"); + const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); + setImmediate(() => { + grpcLess.stderr.push("Error: Failed to find any running emulator\n"); + grpcLess.stderr.push(null); + grpcLess.emit("exit", 1, null); + setTimeout(() => { + fallback.stderr.push("Error: device emulator-5572 not found\n"); + fallback.stderr.push(null); + fallback.emit("exit", 1, null); + }, 20); + }); + + const error = (await factoryPromise.catch((e: unknown) => e)) as Error; + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(error.message).toContain("Error: Failed to find any running emulator"); + expect(getFailureSignal(error)).toMatchObject({ + error_code: "SIMULATOR_SERVER_READY_EXITED", + }); + }); + it("spawns the `android_device` subcommand for a physical Android device", async () => { // A physical phone (kind 'device') is driven by a different simulator-server // controller than an emulator — the screen-sharing-agent path. The blueprint @@ -293,7 +427,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf fakeProc.stderr.push( "[2026-09-23T16:23:35Z INFO simulator_server::media_handler::screenshot_service] Screenshot service stopped\n" ); - fakeProc.stderr.push("Error: Failed to find any running emulator\n"); + fakeProc.stderr.push("Error: Failed to connect to the emulator gRPC endpoint\n"); // `exit` can fire before stderr drains; the reason must still make it in. fakeProc.emit("exit", 1, null); setImmediate(() => fakeProc.stderr.push(null)); @@ -301,7 +435,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf const error = (await factoryPromise.catch((e: unknown) => e)) as Error; expect(error.message).toMatch(/^simulator-server exited with code 1 before becoming ready/); - expect(error.message).toContain("Error: Failed to find any running emulator"); + expect(error.message).toContain("Error: Failed to connect to the emulator gRPC endpoint"); expect(error.message).not.toContain("Screenshot service stopped"); expect(getFailureSignal(error)).toMatchObject({ error_code: "SIMULATOR_SERVER_READY_EXITED", @@ -337,7 +471,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf const device = androidDevice("emulator-5554"); const factoryPromise = simulatorServerBlueprint.factory({}, device, { device }); setImmediate(() => { - fakeProc.stderr.push("Error: Failed to find any running emulator\n"); + fakeProc.stderr.push("Error: Failed to connect to the emulator gRPC endpoint\n"); for (let i = 0; i < 12; i++) { fakeProc.stderr.push( `[2026-09-23T16:23:35Z INFO simulator_server::media_handler] shutdown step ${i}\n` @@ -348,7 +482,7 @@ describe("simulatorServerBlueprint.factory — receives a pre-resolved DeviceInf }); const error = (await factoryPromise.catch((e: unknown) => e)) as Error; - expect(error.message).toContain("Error: Failed to find any running emulator"); + expect(error.message).toContain("Error: Failed to connect to the emulator gRPC endpoint"); expect(error.message).not.toContain("shutdown step"); });